PageSourceSearch

https://andersaamodt.com/static/nav-auth.js?v=1488115087

js andersaamodt.com collected 2026-10-08 10:31:26 UTC 141,058 bytes, 4,172 lines download raw bytes

1(function () {
2  'use strict';
3
4  var AUTH_KIND = 22242;
5  var NIP46_KIND = 24133;
6  var NIP46_RELAYS = [
7    'wss://andersaamodt.com'
8  ];
9
10  var IDB_DB_NAME = 'wizardry-blog-auth';
11  var IDB_STORE_NAME = 'kv';
12  var KEY_DEVICE_SESSION = 'nostr_device_session_v1';
13  var KEY_NIP46_PAIR = 'nostr_nip46_pair_v1';
14  var NAV_TOAST_KEY = 'wizardry_blog_nav_toast_v1';
15  var NOSTR_PAGE_BOOTSTRAP_CACHE_PREFIX = 'nostr_page_bootstrap_v1:';
16  var SITE_TITLE_CACHE_KEY = 'wizardry_blog_site_title_v1';
17  var APPEND_SITE_TITLE_CACHE_KEY = 'wizardry_blog_append_site_title_to_page_title_v1';
18  var THEME_CACHE_KEY = 'wizardry_blog_theme_v1';
19  var PLUGINS_CACHE_KEY = 'wizardry_plugins_v1';
20  var SITE_NOTIFICATION_CURSOR_PREFIX = 'wizardry_site_notification_cursor_v1:';
21  var NOSTR_PAGE_PREFETCH_EXCLUDE = {
22    about: true,
23    blog: true,
24    admin: true,
25    compose: true,
26    account: true,
27    users: true
28  };
29
30  var state = {
31    currentTheme: 'lapidarist',
32    isAuthenticated: false,
33    isAdmin: false,
34    plugins: {
35      nostr_support: true,
36      nostr_login: true,
37      nostr_bridge: true,
38      nostr_posts: true,
39      zaps: true,
40      btcpay: true,
41      video_chat: false
42    },
43    manualChallenge: null,
44    idbPromise: null,
45    nip46: {
46      active: false,
47      appSecretHex: '',
48      appPubkey: '',
49      pairSecret: '',
50      signerPubkey: '',
51      accountPubkey: '',
52      relays: NIP46_RELAYS.slice(),
53      pool: null,
54      subscription: null,
55      pending: {},
56      pendingTimers: {},
57      seenEvents: {},
58      toolsWaitPromise: null,
59      returnRefreshTimer: 0,
60      deepLinkFallbackTimer: 0,
61      lastReturnLoginAttemptAt: 0,
62      autoLoginInFlight: false,
63      diagnostics: {
64        eventsSeen: 0,
65        decryptErrors: 0,
66        ignoredSecrets: 0,
67        lastEventPubkey: '',
68        lastMessage: ''
69      }
70    },
71    prefetchedNostrPageSlugs: {},
72    navOverflowRaf: 0,
73    navOverflowTimer: 0,
74    videoCallPresenceTimer: 0,
75    videoCallNotification: null,
76    videoCallCurrentCallId: '',
77    videoCallAllowAdminCalls: false,
78    siteNotificationTimer: 0,
79    siteNotificationHost: null,
80    secureChatNotificationCursor: 0,
81    seenSiteNotifications: {},
82    requestedNotificationPermission: false,
83    activeAuthTab: 'register',
84    activeAuthFlavor: 'desktop',
85    navOverflowReady: false
86  };
87
88  if (!window.citrine || typeof window.citrine.web.ensureNostrLoginDialog !== 'function') {
89    throw new Error('citrine.web.ensureNostrLoginDialog is required for Nostr login');
90  }
91  window.citrine.web.ensureNostrLoginDialog(document, { title: 'Sign in' });
92
93  var els = {
94    loginBtn: document.getElementById('login-btn'),
95    loginSplit: document.getElementById('nav-login-split'),
96    loginMoreBtn: document.getElementById('login-more-btn'),
97    loginMenu: document.getElementById('nav-login-menu'),
98    loginMenuRegister: document.getElementById('login-menu-register'),
99    loginMenuPhone: document.getElementById('login-menu-phone'),
100    loginMenuManual: document.getElementById('login-menu-manual'),
101    loginMenuLearn: document.getElementById('login-menu-learn'),
102    navToastHost: document.getElementById('nav-top-toast-host'),
103    composeTools: document.getElementById('nav-compose-tools'),
104    composeLink: document.querySelector('.nav-compose'),
105    userMenu: document.getElementById('nav-user-menu'),
106    menuBtn: document.getElementById('nav-menu-btn'),
107    menuPanel: document.getElementById('nav-menu-panel'),
108    navOverflowMenu: document.getElementById('nav-overflow-menu'),
109    navOverflowBtn: document.getElementById('nav-overflow-btn'),
110    navOverflowCount: document.getElementById('nav-overflow-count'),
111    navOverflowPanel: document.getElementById('nav-overflow-panel'),
112    menuPrimaryLink: document.getElementById('nav-menu-primary-link'),
113    menuLogoutBtn: document.getElementById('nav-menu-logout'),
114    menuLogoutMoreBtn: document.getElementById('nav-menu-logout-more'),
115    menuLogoutSubmenu: document.getElementById('nav-menu-logout-submenu'),
116    menuLogoutEverywhereBtn: document.getElementById('nav-menu-logout-everywhere'),
117    userName: document.getElementById('nav-user-name'),
118
119    authModal: document.getElementById('auth-modal'),
120    authModalTitle: document.getElementById('auth-modal-title'),
121    authMessage: document.getElementById('auth-modal-message'),
122    authRegisterBtn: document.getElementById('auth-register-btn'),
123    authPhoneBtn: document.getElementById('auth-phone-btn'),
124    authTabRegister: document.getElementById('auth-tab-register'),
125    authTabPhone: document.getElementById('auth-tab-phone'),
126    authTabManual: document.getElementById('auth-tab-manual'),
127    authLoginSummary: document.getElementById('auth-login-summary'),
128    authLoginApps: document.getElementById('auth-login-apps'),
129    authLoginNote: document.getElementById('auth-login-note'),
130    authZapSummary: document.getElementById('auth-zap-summary'),
131    authZapApps: document.getElementById('auth-zap-apps'),
132    authZapNote: document.getElementById('auth-zap-note'),
133
134    authRegisterPanel: document.getElementById('auth-register-panel'),
135    authPhonePanel: document.getElementById('auth-phone-panel'),
136    authNip46Qr: document.getElementById('auth-nip46-qr'),
137    authNip46Uri: document.getElementById('auth-nip46-uri'),
138    authNip46Open: document.getElementById('auth-nip46-open'),
139    authNip46UriCopy: document.getElementById('auth-nip46-uri-copy'),
140    authNip46Reset: document.getElementById('auth-nip46-reset'),
141    authNip46Diagnostics: document.getElementById('auth-nip46-diagnostics'),
142
143    authManualPanel: document.getElementById('auth-manual-panel'),
144    authManualStart: document.getElementById('auth-manual-start'),
145    authManualRequestId: document.getElementById('auth-manual-request-id'),
146    authManualChallenge: document.getElementById('auth-manual-challenge'),
147    authManualExpires: document.getElementById('auth-manual-expires'),
148    authManualTemplate: document.getElementById('auth-manual-template'),
149    authManualEvent: document.getElementById('auth-manual-event'),
150    authManualSubmit: document.getElementById('auth-manual-submit')
151  };
152
153  var authModalHideTimer = null;
154  var authMessageClearTimer = null;
155  var themeSwitchVisualTimer = null;
156  var themeSwapToken = 0;
157  var sessionCheckRetryTimer = 0;
158  var sessionCheckGraceToken = '';
159  var sessionCheckGraceCount = 0;
160
161  function nowEpoch() {
162    return Math.floor(Date.now() / 1000);
163  }
164
165  function randomHex(bytesLen) {
166    var size = Number(bytesLen || 16);
167    var bytes = new Uint8Array(size);
168    window.crypto.getRandomValues(bytes);
169    return bytesToHex(bytes);
170  }
171
172  function bytesToHex(bytes) {
173    return Array.from(bytes || []).map(function (b) {
174      return b.toString(16).padStart(2, '0');
175    }).join('');
176  }
177
178  function hexToBytes(hex) {
179    var raw = String(hex || '').trim();
180    if (!/^[0-9a-fA-F]+$/.test(raw) || raw.length % 2 !== 0) {
181      throw new Error('Invalid hex input');
182    }
183    var out = new Uint8Array(raw.length / 2);
184    for (var i = 0; i < raw.length; i += 2) {
185      out[i / 2] = parseInt(raw.slice(i, i + 2), 16);
186    }
187    return out;
188  }
189
190  function compact(text) {
191    return String(text || '').replace(/\s+/g, ' ').trim();
192  }
193
194  function shortPubkey(value) {
195    var raw = String(value || '').trim();
196    if (!raw) {
197      return '';
198    }
199    return raw.slice(0, 8);
200  }
201
202  function normalizePlugins(raw) {
203    var src = raw && typeof raw === 'object' ? raw : {};
204    var normalized = {
205      nostr_support: src.nostr_support !== false,
206      nostr_login: src.nostr_login !== false,
207      nostr_bridge: src.nostr_bridge !== false,
208      nostr_posts: src.nostr_posts !== false,
209      zaps: src.zaps !== false,
210      btcpay: src.btcpay !== false,
211      video_chat: src.video_chat === true
212    };
213    if (!normalized.nostr_support) {
214      normalized.nostr_login = false;
215      normalized.nostr_bridge = false;
216      normalized.nostr_posts = false;
217      normalized.zaps = false;
218    }
219    return normalized;
220  }
221
222  function cachePlugins(plugins) {
223    try {
224      localStorage.setItem(PLUGINS_CACHE_KEY, JSON.stringify(plugins || {}));
225    } catch (_err) {
226      // Ignore storage failures.
227    }
228  }
229
230  function readCachedPlugins() {
231    try {
232      var raw = localStorage.getItem(PLUGINS_CACHE_KEY);
233      if (!raw) {
234        return null;
235      }
236      return normalizePlugins(JSON.parse(raw));
237    } catch (_err) {
238      return null;
239    }
240  }
241
242  function publishPlugins(plugins) {
243    state.plugins = normalizePlugins(plugins);
244    cachePlugins(state.plugins);
245    window.__wizardryPlugins = state.plugins;
246    window.__wizardryVideoChatEnabled = !!state.plugins.video_chat;
247    try {
248      window.dispatchEvent(new CustomEvent('wizardry-plugins-ready', {
249        detail: { plugins: state.plugins }
250      }));
251    } catch (_err) {
252      // Ignore event dispatch failures.
253    }
254    syncVideoCallPresencePolling();
255  }
256
257  function syncPluginAuthUi() {
258    if (state.isAuthenticated) {
259      return;
260    }
261    var loginEnabled = !(state.plugins && state.plugins.nostr_login === false);
262    if (!loginEnabled) {
263      if (els.loginSplit) {
264        els.loginSplit.style.display = 'none';
265      } else if (els.loginBtn) {
266        els.loginBtn.style.display = 'none';
267      }
268      return;
269    }
270    if (els.loginSplit) {
271      els.loginSplit.style.display = '';
272    }
273    if (els.loginBtn) {
274      els.loginBtn.style.display = '';
275    }
276  }
277
278  function normalizeSiteTitle(value) {
279    var text = compact(value);
280    return text || 'Site';
281  }
282
283  function normalizeThemeName(value) {
284    var text = compact(value);
285    return text || 'lapidarist';
286  }
287
288  function updateNavSiteSignature(title) {
289    var node = document.getElementById('nav-site-signature');
290    if (!node) {
291      return;
292    }
293    var next = normalizeSiteTitle(title);
294    if (node.textContent !== next) {
295      node.textContent = next;
296    }
297    node.setAttribute('title', next);
298  }
299
300  function syncNavSiteSignatureDestination() {
301    var node = document.getElementById('nav-site-signature');
302    if (!node || !node.setAttribute) {
303      return;
304    }
305    var firstNavLink = document.querySelector('.nav-center a[data-page][href]');
306    var href = firstNavLink ? compact(firstNavLink.getAttribute('href') || '') : '';
307    node.setAttribute('href', href || '/');
308  }
309
310  function cacheSiteTitle(title) {
311    try {
312      localStorage.setItem(SITE_TITLE_CACHE_KEY, normalizeSiteTitle(title));
313    } catch (_err) {
314      // Ignore storage failures.
315    }
316  }
317
318  function normalizeAppendSiteTitleEnabled(value) {
319    return value === true || value === 'true' || value === 1 || value === '1';
320  }
321
322  function cacheAppendSiteTitleEnabled(enabled) {
323    try {
324      localStorage.setItem(APPEND_SITE_TITLE_CACHE_KEY, normalizeAppendSiteTitleEnabled(enabled) ? '1' : '0');
325    } catch (_err) {
326      // Ignore storage failures.
327    }
328  }
329
330  function cacheTheme(theme) {
331    try {
332      localStorage.setItem(THEME_CACHE_KEY, normalizeThemeName(theme));
333    } catch (_err) {
334      // Ignore storage failures.
335    }
336  }
337
338  function readSiteBootstrap() {
339    var bootstrap = window.__wizardrySiteBootstrap;
340    if (!bootstrap || typeof bootstrap !== 'object') {
341      return null;
342    }
343    return bootstrap;
344  }
345
346  function readBootstrapConfig() {
347    var bootstrap = readSiteBootstrap();
348    if (!bootstrap || !bootstrap.config || typeof bootstrap.config !== 'object') {
349      return null;
350    }
351    return bootstrap.config;
352  }
353
354  function readBootstrapNavbarPages() {
355    var bootstrap = readSiteBootstrap();
356    if (!bootstrap || !Array.isArray(bootstrap.navbar_pages) || !bootstrap.navbar_pages.length) {
357      return null;
358    }
359    return bootstrap.navbar_pages;
360  }
361
362  function applySiteConfig(data) {
363    var next = data && typeof data === 'object' ? data : {};
364    var appendSiteTitle = normalizeAppendSiteTitleEnabled(next.append_site_title_to_page_title);
365    updateNavSiteSignature(next.site_title || '');
366    cacheSiteTitle(next.site_title || '');
367    cacheAppendSiteTitleEnabled(appendSiteTitle);
368    window.__wizardrySetPageTitleConfig({
369      siteTitle: next.site_title || '',
370      appendSiteTitle: appendSiteTitle
371    });
372    if (Object.prototype.hasOwnProperty.call(next, 'theme')) {
373      state.currentTheme = normalizeThemeName(next.theme);
374      cacheTheme(state.currentTheme);
375    }
376    if (Object.prototype.hasOwnProperty.call(next, 'plugins')) {
377      publishPlugins(next.plugins || {});
378    }
379    syncPluginAuthUi();
380  }
381
382  function composeIconSvgPaths() {
383    return [
384      {
385        id: "custom:quill-icon-32",
386        name: "quill-icon-32",
387        viewBox: "0 0 256 256",
388        body: '<path fill="currentColor" d="m229.66 58.34l-32-32a8 8 0 0 0-11.32 0l-96 96A8 8 0 0 0 88 128v32a8 8 0 0 0 8 8h32a8 8 0 0 0 5.66-2.34l96-96a8 8 0 0 0 0-11.32M124.69 152H104v-20.69l64-64L188.69 88ZM200 76.69L179.31 56L192 43.31L212.69 64ZM224 128v80a16 16 0 0 1-16 16H48a16 16 0 0 1-16-16V48a16 16 0 0 1 16-16h80a8 8 0 0 1 0 16H48v160h160v-80a8 8 0 0 1 16 0"/>'
389      }
390    ];
391  }
392
393  function readComposeIconIndex() {
394    return 0;
395  }
396
397  function renderComposeIcon(index) {
398    if (!els.composeLink) {
399      return;
400    }
401    var icons = composeIconSvgPaths();
402    var count = icons.length;
403    var idx = Number(index || 0);
404    if (!isFinite(idx) || idx < 0) {
405      idx = 0;
406    }
407    idx = idx % count;
408    var icon = icons[idx];
409    var body = typeof icon === 'string' ? icon : (icon && icon.body ? icon.body : '');
410    var src = (icon && icon.src) ? String(icon.src) : '';
411    var viewBox = (icon && icon.viewBox) ? icon.viewBox : '0 0 24 24';
412    if (src) {
413      els.composeLink.innerHTML = '<img src="' + src + '" width="21" height="21" alt="" aria-hidden="true">';
414    } else {
415      els.composeLink.innerHTML = '<svg width="21" height="21" viewBox="' + viewBox + '" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">' + body + '</svg>';
416    }
417  }
418
419  function waitMs(ms) {
420    var delay = Number(ms || 0);
421    if (!isFinite(delay) || delay < 0) {
422      delay = 0;
423    }
424    return new Promise(function (resolve) {
425      setTimeout(resolve, delay);
426    });
427  }
428
429  function currentHost() {
430    return window.location.host;
431  }
432
433  function currentOrigin() {
434    return window.location.origin;
435  }
436
437  function currentAuthSignatureForCache() {
438    try {
439      var sessionToken = String(localStorage.getItem('session_token') || '').trim();
440      var csrfToken = String(localStorage.getItem('csrf_token') || '').trim();
441      return sessionToken + '|' + csrfToken;
442    } catch (_err) {
443      return '|';
444    }
445  }
446
447  function writeNostrPageBootstrapCache(slug, payload) {
448    var safeSlug = String(slug || '').trim();
449    if (!safeSlug || !payload || typeof payload !== 'object') {
450      return;
451    }
452    try {
453      localStorage.setItem(NOSTR_PAGE_BOOTSTRAP_CACHE_PREFIX + safeSlug, JSON.stringify({
454        auth_signature: currentAuthSignatureForCache(),
455        payload: payload,
456        saved_at: Date.now()
457      }));
458    } catch (_err) {
459      // Ignore cache write failures.
460    }
461  }
462
463  function slugFromHref(href) {
464    var raw = String(href || '').trim();
465    if (!raw || raw.indexOf('javascript:') === 0) {
466      return '';
467    }
468    try {
469      var url = new URL(raw, window.location.href);
470      if (url.origin !== window.location.origin) {
471        return '';
472      }
473      var path = String(url.pathname || '').replace(/\/+$/, '');
474      if (!path || path === '/') {
475        return 'index';
476      }
477      var slug = path;
478      if (slug.indexOf('/pages/') === 0) {
479        slug = slug.slice('/pages/'.length);
480      } else if (slug.indexOf('/') === 0) {
481        slug = slug.slice(1);
482      }
483      slug = slug.replace(/\.html?$/i, '');
484      if (!slug) {
485        slug = 'index';
486      }
487      return slug;
488    } catch (_err) {
489      return '';
490    }
491  }
492
493  function prefetchNostrPageBootstrap(slug) {
494    var safeSlug = String(slug || '').trim();
495    if (!safeSlug || NOSTR_PAGE_PREFETCH_EXCLUDE[safeSlug] || state.prefetchedNostrPageSlugs[safeSlug]) {
496      return;
497    }
498    state.prefetchedNostrPageSlugs[safeSlug] = true;
499    var body = new URLSearchParams({
500      page_slug: safeSlug
501    });
502    fetch('/cgi/blog-get-nostr-page', {
503      method: 'POST',
504      cache: 'no-store',
505      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
506      body: body.toString()
507    })
508      .then(function (res) { return res.text(); })
509      .then(function (text) {
510        var data;
511        try {
512          data = JSON.parse(text);
513        } catch (_parseErr) {
514          return;
515        }
516        if (!data || data.success === false) {
517          return;
518        }
519        writeNostrPageBootstrapCache(safeSlug, data);
520      })
521      .catch(function () {
522        // Ignore prefetch errors.
523      });
524  }
525
526  function bindNavbarNostrPagePrefetch() {
527    var navCenter = document.querySelector('.nav-center');
528    if (!navCenter) {
529      return;
530    }
531
532    function triggerFromTarget(target) {
533      if (!target || !target.closest) {
534        return;
535      }
536      var link = target.closest('a[href]');
537      if (!link) {
538        return;
539      }
540      var slug = slugFromHref(link.getAttribute('href') || '');
541      if (!slug) {
542        return;
543      }
544      prefetchNostrPageBootstrap(slug);
545    }
546
547    navCenter.addEventListener('mouseover', function (event) {
548      triggerFromTarget(event.target);
549    });
550    navCenter.addEventListener('focusin', function (event) {
551      triggerFromTarget(event.target);
552    });
553    navCenter.addEventListener('touchstart', function (event) {
554      triggerFromTarget(event.target);
555    }, { passive: true });
556  }
557
558  function warmNavbarNostrPagePrefetch() {
559    // Intentionally disabled: this eager fanout added avoidable concurrent
560    // bootstrap CGI calls during first paint. Keep prefetch on direct user
561    // intent (hover/focus/touch) via bindNavbarNostrPagePrefetch().
562    return;
563  }
564
565  function hasNostrTools() {
566    return window.citrine.nostr.hasNostrTools(window);
567  }
568
569  function waitForNostrTools(timeoutMs) {
570    if (hasNostrTools()) {
571      state.nip46.toolsWaitPromise = null;
572      return Promise.resolve();
573    }
574    if (state.nip46.toolsWaitPromise) {
575      return state.nip46.toolsWaitPromise;
576    }
577    var timeout = Number(timeoutMs || 8000);
578    if (!isFinite(timeout) || timeout < 1000) {
579      timeout = 8000;
580    }
581    state.nip46.toolsWaitPromise = window.citrine.nostr.waitForNostrTools({
582      target: window,
583      document: document,
584      timeoutMs: timeout,
585      errorMessage: 'Phone signer setup is still loading. The browser may have blocked the Nostr tools script. Advanced signed JSON remains available.'
586    }).then(function () {
587      state.nip46.toolsWaitPromise = null;
588    }).catch(function (err) {
589      state.nip46.toolsWaitPromise = null;
590      throw err;
591    });
592    return state.nip46.toolsWaitPromise;
593  }
594
595  function getBrowserSigner() {
596    try {
597      return window.citrine.nostr.getNip07Signer(window);
598    } catch (_err) {
599      throw new Error('No browser signer detected. Install nos2x-fox or use phone/manual login.');
600    }
601  }
602
603  function ensureAuthMessageEl() {
604    if (!els.authModal || !els.authModal.querySelector) {
605      return null;
606    }
607    var panel = els.authModal.querySelector('.auth-modal-panel');
608    if (!panel) {
609      return null;
610    }
611    var node = (els.authMessage && els.authMessage.isConnected) ? els.authMessage : panel.querySelector('#auth-modal-message');
612    var isNew = !node;
613    if (isNew) {
614      node = document.createElement('div');
615    }
616    node.id = 'auth-modal-message';
617    if (isNew) {
618      node.className = 'auth-modal-message';
619      node.setAttribute('aria-live', 'polite');
620    }
621    var platformGrid = panel.querySelector('.auth-platform-grid');
622    if (platformGrid && platformGrid.parentNode) {
623      platformGrid.parentNode.insertBefore(node, platformGrid);
624    } else {
625      panel.appendChild(node);
626    }
627    els.authMessage = node;
628    return els.authMessage;
629  }
630
631  function setAuthMessage(message, kind) {
632    var target = ensureAuthMessageEl();
633    if (!target) {
634      return;
635    }
636    var text = String(message || '');
637    if (authMessageClearTimer) {
638      clearTimeout(authMessageClearTimer);
639      authMessageClearTimer = null;
640    }
641    if (!text) {
642      target.classList.remove('is-visible');
643      authMessageClearTimer = setTimeout(function () {
644        if (!target.classList.contains('is-visible')) {
645          target.textContent = '';
646          target.className = 'auth-modal-message';
647        }
648        authMessageClearTimer = null;
649      }, 220);
650      return;
651    }
652    target.textContent = text;
653    target.className = 'auth-modal-message';
654    if (kind) {
655      target.classList.add('is-' + kind);
656    }
657    requestAnimationFrame(function () {
658      target.classList.add('is-visible');
659    });
660    setTimeout(function () {
661      target.classList.add('is-visible');
662    }, 20);
663  }
664
665  function setNip46Diagnostics(message, kind) {
666    if (!els.authNip46Diagnostics) {
667      return;
668    }
669    var text = String(message || '');
670    els.authNip46Diagnostics.textContent = text;
671    els.authNip46Diagnostics.className = 'auth-nip46-diagnostics';
672    if (text && kind) {
673      els.authNip46Diagnostics.classList.add('is-' + kind);
674    }
675    state.nip46.diagnostics.lastMessage = text;
676  }
677
678  function isPhonePairingPanelActive() {
679    return state.activeAuthTab === 'phone' && !!els.authPhonePanel && !els.authPhonePanel.hidden;
680  }
681
682  function rememberNavToast(message, tone, durationMs) {
683    try {
684      sessionStorage.setItem(NAV_TOAST_KEY, JSON.stringify({
685        message: String(message || ''),
686        tone: String(tone || 'info'),
687        durationMs: Number(durationMs || 3600),
688        at: Date.now()
689      }));
690    } catch (_err) {
691      // Ignore storage write failures; in-place toasts still work.
692    }
693  }
694
695  function showNavToast(message, tone, durationMs) {
696    var text = String(message || '').trim();
697    if (!text) {
698      return;
699    }
700    var host = els.navToastHost;
701    if (!host) {
702      host = document.createElement('div');
703      host.id = 'nav-top-toast-host';
704      host.className = 'nav-top-toast-host';
705      host.setAttribute('aria-live', 'polite');
706      host.setAttribute('aria-atomic', 'true');
707      document.body.appendChild(host);
708      els.navToastHost = host;
709    }
710    host.innerHTML = '';
711    var toast = document.createElement('div');
712    toast.className = 'nav-top-toast';
713    if (tone) {
714      toast.classList.add('is-' + String(tone));
715    }
716    toast.textContent = text;
717    host.appendChild(toast);
718    requestAnimationFrame(function () {
719      toast.classList.add('is-visible');
720    });
721    var stay = Number(durationMs || 3600);
722    if (!isFinite(stay) || stay < 1200) {
723      stay = 3600;
724    }
725    setTimeout(function () {
726      toast.classList.add('is-closing');
727      setTimeout(function () {
728        if (toast.parentNode) {
729          toast.parentNode.removeChild(toast);
730        }
731      }, 230);
732    }, stay);
733  }
734
735  function flushRememberedNavToast() {
736    var raw = '';
737    try {
738      raw = sessionStorage.getItem(NAV_TOAST_KEY) || '';
739      if (raw) {
740        sessionStorage.removeItem(NAV_TOAST_KEY);
741      }
742    } catch (_err) {
743      raw = '';
744    }
745    if (!raw) {
746      return;
747    }
748    try {
749      var payload = JSON.parse(raw);
750      if (!payload || typeof payload !== 'object') {
751        return;
752      }
753      showNavToast(payload.message || '', payload.tone || 'info', payload.durationMs || 3600);
754    } catch (_err2) {
755      // Ignore malformed persisted toasts.
756    }
757  }
758
759  function requestSignerApproval(signEventFn, template, waitingMessage, timeoutMs) {
760    var waitText = String(waitingMessage || 'Waiting for signer approval...');
761    var timeout = Number(timeoutMs || 70000);
762    if (!isFinite(timeout) || timeout < 1000) {
763      timeout = 70000;
764    }
765    var settled = false;
766    var hintTimer = setTimeout(function () {
767      if (settled) {
768        return;
769      }
770      setAuthMessage(waitText + ' If the signer window is already open, switch to it and approve.', 'warn');
771      try {
772        if (typeof window.focus === 'function') {
773          window.focus();
774        }
775      } catch (_focusErr) {
776        // noop
777      }
778    }, 1200);
779    var timeoutTimer = setTimeout(function () {
780      if (settled) {
781        return;
782      }
783      settled = true;
784      clearTimeout(hintTimer);
785    }, timeout);
786    return Promise.resolve(signEventFn(template)).then(function (result) {
787      if (settled) {
788        throw new Error('Signer approval timed out.');
789      }
790      settled = true;
791      clearTimeout(hintTimer);
792      clearTimeout(timeoutTimer);
793      return result;
794    }).catch(function (err) {
795      clearTimeout(hintTimer);
796      clearTimeout(timeoutTimer);
797      if (settled && (!err || !err.message)) {
798        throw new Error('Signer approval timed out.');
799      }
800      throw err;
801    });
802  }
803
804  function setAuthControlsDisabled(disabled) {
805    var isDisabled = !!disabled;
806    [
807      els.authRegisterBtn,
808      els.authPhoneBtn,
809      els.authManualStart,
810      els.authManualSubmit,
811      els.authTabRegister,
812      els.authTabPhone,
813      els.authTabManual
814    ].forEach(function (node) {
815      if (node) {
816        node.disabled = isDisabled;
817      }
818    });
819    if (els.authModal && typeof els.authModal.querySelectorAll === 'function') {
820      els.authModal.querySelectorAll('[data-auth-route]').forEach(function (node) {
821        node.disabled = isDisabled;
822      });
823    }
824    if (!isDisabled) {
825      updatePhoneContinueState();
826    }
827  }
828
829  function updatePhoneContinueState() {
830    var paired = !!state.nip46.signerPubkey;
831    var controls = els.authPhonePanel && els.authPhonePanel.querySelector
832      ? els.authPhonePanel.querySelector('.auth-nip46-controls')
833      : null;
834    var linkActions = els.authPhonePanel && els.authPhonePanel.querySelector
835      ? els.authPhonePanel.querySelector('.auth-nip46-link-actions')
836      : null;
837    if (controls) {
838      controls.classList.toggle('is-paired', paired);
839    }
840    if (linkActions) {
841      linkActions.hidden = paired;
842    }
843    if (els.authNip46Qr) {
844      els.authNip46Qr.hidden = paired;
845    }
846    if (paired && !hasPendingNip46Requests()) {
847      setNip46Diagnostics('Signer connected. Continue sign-in.', 'ok');
848      setAuthMessage('Phone signer is already paired. Continue sign-in and approve the login request in your signer.', 'plain');
849    }
850    if (!els.authPhoneBtn) {
851      return;
852    }
853    els.authPhoneBtn.disabled = !paired;
854    els.authPhoneBtn.setAttribute('aria-disabled', paired ? 'false' : 'true');
855    els.authPhoneBtn.hidden = !paired;
856    els.authPhoneBtn.textContent = 'Continue sign-in';
857  }
858
859  function resetAuthPanels() {
860    showPanel(els.authRegisterPanel, false);
861    showPanel(els.authPhonePanel, false);
862    showPanel(els.authManualPanel, false);
863    state.manualChallenge = null;
864    if (els.authManualRequestId) { els.authManualRequestId.value = ''; }
865    if (els.authManualChallenge) { els.authManualChallenge.value = ''; }
866    if (els.authManualExpires) { els.authManualExpires.value = ''; }
867    if (els.authManualTemplate) { els.authManualTemplate.value = ''; }
868    if (els.authManualEvent) { els.authManualEvent.value = ''; }
869  }
870
871  function authRecommendationIconAssets() {
872    return {
873      amber: '/static/icons/apps/amber.svg',
874      amethyst: '/static/icons/apps/amethyst.png',
875      damus: '/static/icons/apps/damus.png',
876      nostur: '/static/icons/apps/nostur.png',
877      nos2x: '/static/icons/apps/nos2x-fox.svg',
878      zeus: '/static/icons/apps/zeus.png'
879    };
880  }
881
882  function renderAuthOnboarding(tabName, flavor) {
883    window.citrine.web.renderNostrRecommendations({
884      tab: tabName,
885      flavor: flavor,
886      loginSummary: els.authLoginSummary,
887      loginApps: els.authLoginApps,
888      loginNote: els.authLoginNote,
889      zapSummary: els.authZapSummary,
890      zapApps: els.authZapApps,
891      zapNote: els.authZapNote,
892      iconAssets: authRecommendationIconAssets()
893    });
894  }
895
896  function updateAuthPlatformCards(tab, flavor) {
897    if (!els.authModal || typeof els.authModal.querySelectorAll !== 'function') {
898      return;
899    }
900    var activeTab = String(tab || 'register');
901    var activeFlavor = String(flavor || '');
902    els.authModal.querySelectorAll('[data-auth-route]').forEach(function (button) {
903      var route = String(button.getAttribute('data-auth-route') || '');
904      var cardFlavor = String(button.getAttribute('data-auth-flavor') || '');
905      var active = route === activeTab && (!activeFlavor || cardFlavor === activeFlavor || (activeTab !== 'phone' && !activeFlavor));
906      button.classList.toggle('is-active', active);
907      button.setAttribute('aria-pressed', active ? 'true' : 'false');
908    });
909  }
910
911  function setActiveAuthTab(tabName, phoneFlavor) {
912    var tab = String(tabName || 'register');
913    if (tab !== 'register' && tab !== 'phone' && tab !== 'manual') {
914      tab = 'register';
915    }
916    var flavor = String(phoneFlavor || (tab === 'phone' ? 'android' : tab)).trim() || tab;
917    state.activeAuthTab = tab;
918    state.activeAuthFlavor = flavor;
919    if (els.authModalTitle) {
920      els.authModalTitle.textContent = 'Sign in';
921    }
922    updateAuthPlatformCards(tab, flavor);
923    renderAuthOnboarding(tab, flavor);
924
925    if (els.authTabRegister) {
926      var activeRegister = tab === 'register';
927      els.authTabRegister.classList.toggle('is-active', activeRegister);
928      els.authTabRegister.setAttribute('aria-pressed', activeRegister ? 'true' : 'false');
929    }
930    if (els.authTabPhone) {
931      var activePhone = tab === 'phone' && flavor === 'android';
932      els.authTabPhone.classList.toggle('is-active', activePhone);
933      els.authTabPhone.setAttribute('aria-pressed', activePhone ? 'true' : 'false');
934    }
935    if (els.authTabManual) {
936      var activeManual = tab === 'manual';
937      els.authTabManual.classList.toggle('is-active', activeManual);
938      els.authTabManual.setAttribute('aria-pressed', activeManual ? 'true' : 'false');
939    }
940
941    showPanel(els.authRegisterPanel, tab === 'register');
942    showPanel(els.authPhonePanel, tab === 'phone');
943    showPanel(els.authManualPanel, tab === 'manual');
944
945    if (tab === 'phone') {
946      updatePhoneContinueState();
947      setAuthMessage(window.citrine.web.signInHelperMessage(tab), 'plain');
948      initNip46Pairing().then(function () {
949        updatePhoneContinueState();
950      }).catch(function (err) {
951        setAuthMessage(err.message || 'Unable to prepare phone signer QR.', 'error');
952      });
953      return;
954    }
955    if (tab === 'manual') {
956      setAuthMessage(window.citrine.web.signInHelperMessage(tab), 'plain');
957      return;
958    }
959    setAuthMessage(window.citrine.web.signInHelperMessage(tab), 'plain');
960  }
961
962  function showAuthModal(initialTab) {
963    if (!els.authModal) {
964      return;
965    }
966    var initialSelection = normalizeAuthInitialSelection(initialTab);
967    syncAuthModalNavOffset();
968    if (authModalHideTimer) {
969      clearTimeout(authModalHideTimer);
970      authModalHideTimer = null;
971    }
972    els.authModal.hidden = false;
973    requestAnimationFrame(function () {
974      syncAuthModalNavOffset();
975      els.authModal.classList.add('is-open');
976    });
977    document.body.classList.add('auth-modal-open');
978    resetAuthPanels();
979    setAuthControlsDisabled(false);
980    setActiveAuthTab(initialSelection.tab, initialSelection.flavor);
981  }
982
983  function hideAuthModal() {
984    if (!els.authModal) {
985      return;
986    }
987    els.authModal.classList.remove('is-open');
988    document.body.classList.remove('auth-modal-open');
989    setAuthMessage('', '');
990    setAuthControlsDisabled(false);
991    if (authModalHideTimer) {
992      clearTimeout(authModalHideTimer);
993    }
994    authModalHideTimer = setTimeout(function () {
995      if (!els.authModal.classList.contains('is-open')) {
996        els.authModal.hidden = true;
997      }
998      authModalHideTimer = null;
999    }, 210);
1000  }
1001
1002  function syncAuthModalNavOffset() {
1003    if (!els.authModal) {
1004      return;
1005    }
1006    var offset = 0;
1007    if (window.matchMedia && window.matchMedia('(max-width: 640px)').matches) {
1008      var nav = document.querySelector('nav.site-nav');
1009      if (nav) {
1010        var rect = nav.getBoundingClientRect();
1011        if (rect && rect.bottom > 0) {
1012          offset = Math.ceil(rect.bottom);
1013        }
1014      }
1015    }
1016    els.authModal.style.setProperty('--auth-modal-nav-offset', String(offset) + 'px');
1017  }
1018
1019  function showPanel(panel, show) {
1020    if (!panel) {
1021      return;
1022    }
1023    panel.hidden = !show;
1024  }
1025
1026  function parseJsonResponse(text) {
1027    try {
1028      return JSON.parse(text);
1029    } catch (_) {
1030      var c = compact(text || '');
1031      if (!c) {
1032        throw new Error('Invalid JSON response');
1033      }
1034      throw new Error('Unexpected server response: ' + c.slice(0, 180));
1035    }
1036  }
1037
1038  function fetchJson(url, options) {
1039    var request = Object.assign({ cache: 'no-store' }, options || {});
1040    return fetch(url, request)
1041      .then(function (res) {
1042        return res.text().then(function (text) {
1043          return parseJsonResponse(text);
1044        });
1045      });
1046  }
1047
1048  function postForm(url, payload) {
1049    var body = new URLSearchParams(payload || {});
1050    return fetchJson(url, {
1051      method: 'POST',
1052      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
1053      body: body.toString()
1054    });
1055  }
1056
1057  function videoCallAuthPayload(payload) {
1058    var next = payload && typeof payload === 'object' ? Object.assign({}, payload) : {};
1059    next.session_token = getSessionToken();
1060    next.csrf_token = getCsrfToken();
1061    return next;
1062  }
1063
1064  function siteNotificationCursorKey(kind) {
1065    var host = 'site';
1066    try {
1067      host = String(window.location.hostname || 'site').toLowerCase();
1068    } catch (_err) {
1069      host = 'site';
1070    }
1071    return SITE_NOTIFICATION_CURSOR_PREFIX + host + ':' + String(kind || 'default');
1072  }
1073
1074  function notificationTag(kind, id) {
1075    return String(kind || 'site') + ':' + String(id || '');
1076  }
1077
1078  function ensureSiteNotificationHost() {
1079    if (state.siteNotificationHost && state.siteNotificationHost.parentNode) {
1080      return state.siteNotificationHost;
1081    }
1082    var host = document.getElementById('site-notification-host');
1083    if (!host) {
1084      host = document.createElement('div');
1085      host.id = 'site-notification-host';
1086      host.setAttribute('aria-live', 'polite');
1087      document.body.appendChild(host);
1088    }
1089    state.siteNotificationHost = host;
1090    return host;
1091  }
1092
1093  function ensureSiteNotificationStyles() {
1094    if (document.getElementById('site-notification-styles')) {
1095      return;
1096    }
1097    var style = document.createElement('style');
1098    style.id = 'site-notification-styles';
1099    style.textContent = ''
1100      + '#site-notification-host{position:fixed;right:16px;bottom:18px;z-index:2147482600;display:flex;flex-direction:column;gap:10px;max-width:min(23rem,calc(100vw - 32px));pointer-events:none;}'
1101      + '.site-notification-card{background:#fffaf1;color:#241b12;border:1px solid rgba(98,75,42,.28);box-shadow:0 16px 40px rgba(36,27,18,.2);border-radius:10px;padding:12px;font-family:Georgia,Times New Roman,serif;pointer-events:auto;}'
1102      + '.site-notification-card strong{display:block;font-size:1rem;line-height:1.2;margin:0 0 5px;}'
1103      + '.site-notification-card p{margin:0;color:#5c4b36;font-size:.92rem;line-height:1.35;}'
1104      + '.site-notification-actions{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-top:9px;}'
1105      + '.site-notification-card button,.site-notification-card a{appearance:none;border:1px solid rgba(98,75,42,.36);background:#f5ead7;color:#241b12;border-radius:999px;padding:7px 12px;font:inherit;font-size:.92rem;line-height:1;text-decoration:none;cursor:pointer;}'
1106      + '.site-notification-card .primary{background:#2f63be;border-color:#2b56a4;color:white;}';
1107    document.head.appendChild(style);
1108  }
1109
1110  function showBrowserNotification(title, body, tag, url) {
1111    if (!('Notification' in window) || window.Notification.permission !== 'granted') {
1112      return;
1113    }
1114    try {
1115      var note = new window.Notification(title, {
1116        body: body,
1117        tag: tag,
1118        renotify: false
1119      });
1120      note.onclick = function () {
1121        try {
1122          window.focus();
1123        } catch (_err) {}
1124        if (url) {
1125          window.location.href = url;
1126        }
1127        note.close();
1128      };
1129    } catch (_err) {
1130      // Browser notifications are optional; the in-page card is authoritative.
1131    }
1132  }
1133
1134  function showSiteNotification(options) {
1135    var opts = options && typeof options === 'object' ? options : {};
1136    var id = notificationTag(opts.kind || 'site', opts.id || opts.title || Date.now());
1137    if (state.seenSiteNotifications[id]) {
1138      return;
1139    }
1140    state.seenSiteNotifications[id] = true;
1141    ensureSiteNotificationStyles();
1142    var host = ensureSiteNotificationHost();
1143    var card = document.createElement('section');
1144    card.className = 'site-notification-card';
1145    card.setAttribute('role', opts.assertive ? 'alertdialog' : 'status');
1146    card.setAttribute('aria-live', opts.assertive ? 'assertive' : 'polite');
1147    var primaryUrl = String(opts.url || '');
1148    card.innerHTML = ''
1149      + '<strong>' + escapeHtml(opts.title || 'Site notification') + '</strong>'
1150      + '<p>' + escapeHtml(opts.body || '') + '</p>'
1151      + '<div class="site-notification-actions">'
1152      + (primaryUrl ? '<a class="primary" href="' + escapeHtml(primaryUrl) + '">' + escapeHtml(opts.actionLabel || 'Open') + '</a>' : '')
1153      + '<button type="button" data-site-notification-dismiss="true">Dismiss</button>'
1154      + '</div>';
1155    card.addEventListener('click', function (event) {
1156      var dismiss = event.target instanceof Element ? event.target.closest('[data-site-notification-dismiss]') : null;
1157      if (dismiss && card.parentNode) {
1158        card.parentNode.removeChild(card);
1159      }
1160    });
1161    host.appendChild(card);
1162    window.setTimeout(function () {
1163      if (card.parentNode) {
1164        card.parentNode.removeChild(card);
1165      }
1166    }, opts.assertive ? 45000 : 18000);
1167    showBrowserNotification(opts.title || 'Site notification', opts.body || '', id, primaryUrl);
1168  }
1169
1170  function ensureVideoCallNotificationStyles() {
1171    if (document.getElementById('video-call-notification-styles')) {
1172      return;
1173    }
1174    var style = document.createElement('style');
1175    style.id = 'video-call-notification-styles';
1176    style.textContent = ''
1177      + '.video-call-notification{position:fixed;right:16px;bottom:18px;z-index:2147482600;max-width:min(22rem,calc(100vw - 32px));background:#fffaf1;color:#241b12;border:1px solid rgba(98,75,42,.28);box-shadow:0 16px 40px rgba(36,27,18,.2);border-radius:10px;padding:12px;display:flex;flex-direction:column;gap:9px;font-family:Georgia,Times New Roman,serif;}'
1178      + '.video-call-notification strong{font-size:1rem;line-height:1.2;}'
1179      + '.video-call-notification p{margin:0;color:#5c4b36;font-size:.92rem;line-height:1.35;}'
1180      + '.video-call-notification-actions{display:flex;flex-wrap:wrap;gap:8px;align-items:center;}'
1181      + '.video-call-notification button{appearance:none;border:1px solid rgba(98,75,42,.36);background:#f5ead7;color:#241b12;border-radius:999px;padding:7px 12px;font:inherit;font-size:.92rem;line-height:1;cursor:pointer;}'
1182      + '.video-call-notification button.primary{background:#2f63be;border-color:#2b56a4;color:white;}'
1183      + '.video-call-notification button:hover{filter:brightness(.98);}';
1184    document.head.appendChild(style);
1185  }
1186
1187  function hideVideoCallNotification(callId) {
1188    if (callId && state.videoCallCurrentCallId && callId !== state.videoCallCurrentCallId) {
1189      return;
1190    }
1191    state.videoCallCurrentCallId = '';
1192    if (state.videoCallNotification && state.videoCallNotification.parentNode) {
1193      state.videoCallNotification.parentNode.removeChild(state.videoCallNotification);
1194    }
1195    state.videoCallNotification = null;
1196  }
1197
1198  function answerVideoCall(callId) {
1199    return postForm('/cgi/blog-video-chat-control', videoCallAuthPayload({
1200      action: 'answer_call',
1201      call_id: callId
1202    })).then(function (data) {
1203      if (!data || !data.success) {
1204        throw new Error((data && data.error) || 'Could not answer call.');
1205      }
1206      hideVideoCallNotification(callId);
1207      var roomId = String(data.room_id || (data.call && data.call.room_id) || '').trim();
1208      var roomPassword = String(data.room_password || (data.call && data.call.room_password) || '').trim();
1209      var callMode = String((data.call && data.call.call_mode) || 'video') === 'voice' ? 'voice' : 'video';
1210      if (roomId) {
1211        var url = '/contact?room=' + encodeURIComponent(roomId) + '&auto_start=1&mode=' + encodeURIComponent(callMode);
1212        if (callId) {
1213          url += '&call_id=' + encodeURIComponent(callId);
1214        }
1215        if (roomPassword) {
1216          url += '&room_password=' + encodeURIComponent(roomPassword);
1217        }
1218        window.location.href = url;
1219      }
1220    }).catch(function () {
1221      hideVideoCallNotification(callId);
1222    });
1223  }
1224
1225  function declineVideoCall(callId) {
1226    return postForm('/cgi/blog-video-chat-control', videoCallAuthPayload({
1227      action: 'decline_call',
1228      call_id: callId
1229    })).catch(function () {
1230      return false;
1231    }).then(function () {
1232      hideVideoCallNotification(callId);
1233    });
1234  }
1235
1236  function showVideoCallNotification(call) {
1237    if (!call || !call.call_id) {
1238      return;
1239    }
1240    var callId = String(call.call_id);
1241    if (state.videoCallCurrentCallId === callId && state.videoCallNotification) {
1242      return;
1243    }
1244    hideVideoCallNotification();
1245    ensureVideoCallNotificationStyles();
1246    var node = document.createElement('section');
1247    node.className = 'video-call-notification';
1248    node.setAttribute('role', 'alertdialog');
1249    node.setAttribute('aria-live', 'assertive');
1250    var isSelfTest = !!call.self_test;
1251    var isOwnerCall = !!call.owner_call;
1252    var callMode = String(call.call_mode || 'video') === 'voice' ? 'voice' : 'video';
1253    var callKind = callMode === 'voice' ? 'voice call' : 'video call';
1254    var title = isOwnerCall ? ('Private ' + callKind + ' for Anders') : (isSelfTest ? ('Self-test ' + callKind) : ('Incoming ' + callKind));
1255    var body = isOwnerCall ? escapeHtml(call.from_admin_name || 'Website visitor') + ' is waiting in a private 1:1 room.' : (isSelfTest ? 'You started a test call to this signed-in account.' : escapeHtml(call.from_admin_name || call.from_admin || 'Site admin') + ' is calling you on this site.');
1256    node.innerHTML = ''
1257      + '<strong>' + escapeHtml(title) + '</strong>'
1258      + '<p>' + body + '</p>'
1259      + '<div class="video-call-notification-actions">'
1260      + '<button type="button" class="primary" data-video-call-action="answer">Answer</button>'
1261      + '<button type="button" data-video-call-action="decline">Decline</button>'
1262      + '</div>';
1263    node.addEventListener('click', function (event) {
1264      var button = event.target instanceof Element ? event.target.closest('[data-video-call-action]') : null;
1265      if (!button) {
1266        return;
1267      }
1268      var action = button.getAttribute('data-video-call-action');
1269      if (action === 'answer') {
1270        answerVideoCall(callId);
1271      } else if (action === 'decline') {
1272        declineVideoCall(callId);
1273      }
1274    });
1275    document.body.appendChild(node);
1276    state.videoCallNotification = node;
1277    state.videoCallCurrentCallId = callId;
1278    showBrowserNotification(title, String(node.querySelector('p') && node.querySelector('p').textContent || ''), notificationTag('video-call', callId), '');
1279  }
1280
1281  function pollVideoCallPresence() {
1282    if (!state.isAuthenticated || !(state.plugins && state.plugins.video_chat) || !hasStoredSessionToken() || !getCsrfToken()) {
1283      hideVideoCallNotification();
1284      return Promise.resolve(false);
1285    }
1286    var roomId = '';
1287    try {
1288      roomId = String(window.__wizardryVideoChatRoomId || '').trim();
1289    } catch (_err) {
1290      roomId = '';
1291    }
1292    return postForm('/cgi/blog-video-chat-control', videoCallAuthPayload({
1293      action: 'heartbeat',
1294      current_room: roomId,
1295      status: roomId ? 'in-room' : 'online',
1296      page_url: window.location.pathname + window.location.search
1297    })).then(function (data) {
1298      if (!data || !data.success) {
1299        return false;
1300      }
1301      state.videoCallAllowAdminCalls = !!data.allow_admin_calls;
1302      var calls = Array.isArray(data.incoming_calls) ? data.incoming_calls : [];
1303      if (calls.length) {
1304        showVideoCallNotification(calls[0]);
1305      } else {
1306        hideVideoCallNotification();
1307      }
1308      return true;
1309    }).catch(function () {
1310      return false;
1311    });
1312  }
1313
1314  function syncVideoCallPresencePolling() {
1315    if (state.videoCallPresenceTimer) {
1316      window.clearInterval(state.videoCallPresenceTimer);
1317      state.videoCallPresenceTimer = 0;
1318    }
1319    if (!state.isAuthenticated || !(state.plugins && state.plugins.video_chat) || !hasStoredSessionToken()) {
1320      hideVideoCallNotification();
1321      return;
1322    }
1323    pollVideoCallPresence();
1324    state.videoCallPresenceTimer = window.setInterval(function () {
1325      pollVideoCallPresence();
1326    }, 12000);
1327  }
1328
1329  function pollSecureChatAdminNotifications() {
1330    if (!state.isAuthenticated || !state.isAdmin || !hasStoredSessionToken() || !getCsrfToken()) {
1331      return Promise.resolve(false);
1332    }
1333    var cursor = Number(state.secureChatNotificationCursor || localStorage.getItem(siteNotificationCursorKey('secure-chat')) || 0);
1334    if (!isFinite(cursor) || cursor < 0) {
1335      cursor = 0;
1336    }
1337    return postForm('/cgi/blog-secure-chat-admin', videoCallAuthPayload({
1338      action: 'notification-status',
1339      since_seq: String(cursor)
1340    })).then(function (data) {
1341      if (!data || !data.success) {
1342        return false;
1343      }
1344      var nextCursor = Number(data.cursor_seq || cursor || 0);
1345      var messages = Array.isArray(data.messages) ? data.messages : [];
1346      if (cursor > 0) {
1347        messages.forEach(function (message) {
1348          var seq = Number(message && message.seq || 0);
1349          if (seq <= cursor) {
1350            return;
1351          }
1352          var text = compact(message && message.text || '');
1353          showSiteNotification({
1354            kind: 'secure-chat',
1355            id: message && (message.id || message.seq),
1356            title: 'Private message',
1357            body: (message && message.contact_name ? String(message.contact_name) + ': ' : '') + (text || 'New Secure Chat message'),
1358            url: '/admin#account',
1359            actionLabel: 'Open',
1360            assertive: false
1361          });
1362        });
1363      }
1364      if (nextCursor > cursor) {
1365        state.secureChatNotificationCursor = nextCursor;
1366        localStorage.setItem(siteNotificationCursorKey('secure-chat'), String(nextCursor));
1367      }
1368      return true;
1369    }).catch(function () {
1370      return false;
1371    });
1372  }
1373
1374  function syncSiteNotificationPolling() {
1375    if (state.siteNotificationTimer) {
1376      window.clearInterval(state.siteNotificationTimer);
1377      state.siteNotificationTimer = 0;
1378    }
1379    if (!state.isAuthenticated || !state.isAdmin || !hasStoredSessionToken()) {
1380      return;
1381    }
1382    pollSecureChatAdminNotifications();
1383    state.siteNotificationTimer = window.setInterval(function () {
1384      pollSecureChatAdminNotifications();
1385    }, 30000);
1386  }
1387
1388  function encodeBase64Utf8(text) {
1389    var raw = String(text || '');
1390    try {
1391      return btoa(unescape(encodeURIComponent(raw)));
1392    } catch (_err) {
1393      return btoa(raw);
1394    }
1395  }
1396
1397  function getSessionToken() {
1398    return localStorage.getItem('session_token') || '';
1399  }
1400
1401  function getCsrfToken() {
1402    return localStorage.getItem('csrf_token') || '';
1403  }
1404
1405  function hasStoredSessionToken() {
1406    var token = String(getSessionToken() || '').trim();
1407    return !!token && token !== 'null' && token !== 'undefined';
1408  }
1409
1410  function emitAuthChanged(detail) {
1411    var extra = detail && typeof detail === 'object' ? detail : {};
1412    try {
1413      window.dispatchEvent(new CustomEvent('blog-auth-changed', {
1414        detail: Object.assign({
1415          session_token: getSessionToken(),
1416          csrf_token: getCsrfToken()
1417        }, extra)
1418      }));
1419    } catch (_err) {
1420      // Ignore event dispatch failures.
1421    }
1422  }
1423
1424  function rememberAuth(data) {
1425    localStorage.setItem('session_token', data.session_token || '');
1426    localStorage.setItem('csrf_token', data.csrf_token || '');
1427    localStorage.setItem('last_auth_method', 'nostr');
1428    if (data.player_name) {
1429      localStorage.setItem('last_auth_player_name', data.player_name);
1430    }
1431    if (data.username) {
1432      localStorage.setItem('last_auth_username', data.username);
1433    }
1434    if (data.pubkey) {
1435      localStorage.setItem('last_auth_pubkey', data.pubkey);
1436    }
1437  }
1438
1439  function clearLocalStorageAuth() {
1440    localStorage.removeItem('session_token');
1441    localStorage.removeItem('csrf_token');
1442    localStorage.removeItem('last_auth_method');
1443    emitAuthChanged();
1444  }
1445
1446  function resetSessionCheckGrace() {
1447    sessionCheckGraceToken = '';
1448    sessionCheckGraceCount = 0;
1449    if (sessionCheckRetryTimer) {
1450      window.clearTimeout(sessionCheckRetryTimer);
1451      sessionCheckRetryTimer = 0;
1452    }
1453  }
1454
1455  function hasStoredNostrAuthHint() {
1456    var method = String(localStorage.getItem('last_auth_method') || '').trim().toLowerCase();
1457    var pubkey = String(localStorage.getItem('last_auth_pubkey') || '').trim().toLowerCase();
1458    return method === 'nostr' || !!pubkey;
1459  }
1460
1461  function scheduleSessionCheckRetry(token) {
1462    var retryToken = String(token || '').trim();
1463    if (!retryToken) {
1464      return;
1465    }
1466    if (sessionCheckRetryTimer) {
1467      window.clearTimeout(sessionCheckRetryTimer);
1468    }
1469    sessionCheckRetryTimer = window.setTimeout(function () {
1470      sessionCheckRetryTimer = 0;
1471      var currentToken = String(getSessionToken() || '').trim();
1472      if (!currentToken || currentToken !== retryToken) {
1473        return;
1474      }
1475      checkAuth();
1476    }, 1200);
1477  }
1478
1479  function openAuthDb() {
1480    if (!window.indexedDB) {
1481      return Promise.resolve(null);
1482    }
1483    if (state.idbPromise) {
1484      return state.idbPromise;
1485    }
1486    state.idbPromise = new Promise(function (resolve, reject) {
1487      var req = window.indexedDB.open(IDB_DB_NAME, 1);
1488      req.onupgradeneeded = function () {
1489        var db = req.result;
1490        if (!db.objectStoreNames.contains(IDB_STORE_NAME)) {
1491          db.createObjectStore(IDB_STORE_NAME);
1492        }
1493      };
1494      req.onsuccess = function () {
1495        resolve(req.result);
1496      };
1497      req.onerror = function () {
1498        reject(req.error || new Error('IndexedDB unavailable'));
1499      };
1500    }).catch(function () {
1501      return null;
1502    });
1503    return state.idbPromise;
1504  }
1505
1506  function fallbackKey(key) {
1507    return 'wizardry_blog_auth_fallback_' + key;
1508  }
1509
1510  function idbSet(key, value) {
1511    return openAuthDb().then(function (db) {
1512      if (!db) {
1513        localStorage.setItem(fallbackKey(key), JSON.stringify(value));
1514        return;
1515      }
1516      return new Promise(function (resolve, reject) {
1517        var tx = db.transaction(IDB_STORE_NAME, 'readwrite');
1518        tx.objectStore(IDB_STORE_NAME).put(value, key);
1519        tx.oncomplete = function () { resolve(); };
1520        tx.onerror = function () { reject(tx.error || new Error('IndexedDB write failed')); };
1521      });
1522    });
1523  }
1524
1525  function idbGet(key) {
1526    return openAuthDb().then(function (db) {
1527      if (!db) {
1528        var raw = localStorage.getItem(fallbackKey(key));
1529        if (!raw) {
1530          return null;
1531        }
1532        try {
1533          return JSON.parse(raw);
1534        } catch (_) {
1535          return null;
1536        }
1537      }
1538      return new Promise(function (resolve, reject) {
1539        var tx = db.transaction(IDB_STORE_NAME, 'readonly');
1540        var req = tx.objectStore(IDB_STORE_NAME).get(key);
1541        req.onsuccess = function () { resolve(req.result || null); };
1542        req.onerror = function () { reject(req.error || new Error('IndexedDB read failed')); };
1543      });
1544    });
1545  }
1546
1547  function idbDelete(key) {
1548    return openAuthDb().then(function (db) {
1549      if (!db) {
1550        localStorage.removeItem(fallbackKey(key));
1551        return;
1552      }
1553      return new Promise(function (resolve, reject) {
1554        var tx = db.transaction(IDB_STORE_NAME, 'readwrite');
1555        tx.objectStore(IDB_STORE_NAME).delete(key);
1556        tx.oncomplete = function () { resolve(); };
1557        tx.onerror = function () { reject(tx.error || new Error('IndexedDB delete failed')); };
1558      });
1559    });
1560  }
1561
1562  function clearLocalKeyMaterial() {
1563    return Promise.all([
1564      idbDelete(KEY_DEVICE_SESSION),
1565      idbDelete(KEY_NIP46_PAIR)
1566    ]).then(function () {
1567      state.nip46.active = false;
1568      state.nip46.signerPubkey = '';
1569      state.nip46.accountPubkey = '';
1570      state.nip46.appSecretHex = '';
1571      state.nip46.appPubkey = '';
1572      state.nip46.pairSecret = '';
1573      state.nip46.pending = {};
1574      state.nip46.pendingTimers = {};
1575      state.nip46.seenEvents = {};
1576      state.nip46.toolsWaitPromise = null;
1577      if (state.nip46.returnRefreshTimer) {
1578        clearTimeout(state.nip46.returnRefreshTimer);
1579        state.nip46.returnRefreshTimer = 0;
1580      }
1581      if (state.nip46.deepLinkFallbackTimer) {
1582        clearTimeout(state.nip46.deepLinkFallbackTimer);
1583        state.nip46.deepLinkFallbackTimer = 0;
1584      }
1585      state.nip46.lastReturnLoginAttemptAt = 0;
1586      state.nip46.autoLoginInFlight = false;
1587      state.nip46.diagnostics = {
1588        eventsSeen: 0,
1589        decryptErrors: 0,
1590        ignoredSecrets: 0,
1591        lastEventPubkey: '',
1592        lastMessage: ''
1593      };
1594      if (state.nip46.subscription && typeof state.nip46.subscription.close === 'function') {
1595        state.nip46.subscription.close();
1596      }
1597      state.nip46.subscription = null;
1598      if (state.nip46.pool && typeof state.nip46.pool.destroy === 'function') {
1599        state.nip46.pool.destroy();
1600      }
1601      state.nip46.pool = null;
1602    });
1603  }
1604
1605  function authEventTemplate(challenge, action, pubkey) {
1606    var eventAction = action || 'login';
1607    var signerPubkey = String(pubkey || '').trim();
1608    return window.citrine.nostr.createAuthEventTemplate({
1609      kind: AUTH_KIND,
1610      challenge: challenge,
1611      action: eventAction,
1612      pubkey: signerPubkey,
1613      origin: currentOrigin(),
1614      domain: currentHost()
1615    });
1616  }
1617
1618  function normalizeSignedEvent(result) {
1619    if (typeof result === 'string') {
1620      return parseJsonResponse(result);
1621    }
1622    if (result && typeof result === 'object') {
1623      return result;
1624    }
1625    throw new Error('Signer did not return a valid signed event.');
1626  }
1627
1628  function normalizeSignedEventKind(value) {
1629    var num = Number(value);
1630    if (!Number.isFinite(num) || num <= 0) {
1631      return 0;
1632    }
1633    return Math.floor(num);
1634  }
1635
1636  function normalizeSignedEventForSubmit(result, pubkey, expectedKind) {
1637    var normalized = normalizeSignedEvent(result);
1638    var fallbackPubkey = normalizePubkeyHex(pubkey);
1639    var rawExistingPubkey = String((normalized && normalized.pubkey) || '').trim();
1640    var existingPubkey = normalizePubkeyHex(rawExistingPubkey);
1641    var desiredKind = normalizeSignedEventKind(expectedKind);
1642    var existingKind = normalizeSignedEventKind(normalized && normalized.kind);
1643    var patched = normalized;
1644    if (normalized && fallbackPubkey && (!existingPubkey || rawExistingPubkey.toLowerCase() !== existingPubkey)) {
1645      patched = Object.assign({}, patched, { pubkey: fallbackPubkey });
1646    }
1647    if (patched && desiredKind > 0 && existingKind !== desiredKind) {
1648      patched = Object.assign({}, patched, { kind: desiredKind });
1649    }
1650    return patched;
1651  }
1652
1653  function signedEventPubkey(result) {
1654    var normalized = normalizeSignedEvent(result);
1655    return String((normalized && normalized.pubkey) || '').trim();
1656  }
1657
1658  function normalizePubkeyHex(value) {
1659    return window.citrine.nostr.normalizeNostrPubkey(value, window.NostrTools);
1660  }
1661
1662  function beginChallenge(pubkeyHint) {
1663    var payload = {};
1664    if (pubkeyHint) {
1665      payload.pubkey_hint = pubkeyHint;
1666    }
1667    return postForm('/cgi/nostr-auth-login-begin', payload)
1668      .then(function (data) {
1669        if (!data || !data.success) {
1670          throw new Error((data && data.error) || 'Unable to create login challenge.');
1671        }
1672        return data;
1673      });
1674  }
1675
1676  function finishLogin(requestId, signedEvent, delegationEvent, forceInteractive, usernameHint, signerPubkeyHint) {
1677    var payload = {
1678      request_id: requestId,
1679      event_json_b64: encodeBase64Utf8(JSON.stringify(normalizeSignedEventForSubmit(signedEvent, signerPubkeyHint, AUTH_KIND))),
1680      force_interactive: forceInteractive ? 'true' : 'false'
1681    };
1682    var normalizedSignerPubkeyHint = normalizePubkeyHex(signerPubkeyHint);
1683    var desiredUsername = String(usernameHint || '').trim();
1684    if (desiredUsername) {
1685      payload.username_hint = desiredUsername;
1686    }
1687    if (normalizedSignerPubkeyHint) {
1688      payload.signer_pubkey_hint = normalizedSignerPubkeyHint;
1689    }
1690    if (delegationEvent) {
1691      payload.delegation_json_b64 = encodeBase64Utf8(JSON.stringify(normalizeSignedEvent(delegationEvent)));
1692    }
1693    return postForm('/cgi/nostr-auth-login-finish', payload)
1694      .then(function (data) {
1695        if (!data || !data.success) {
1696          throw new Error((data && data.error) || 'Nostr login failed.');
1697        }
1698        return data;
1699      });
1700  }
1701
1702  function applyLoggedInUi(isLoggedIn, isAdmin, username) {
1703    var displayName = String(username || '');
1704    state.isAuthenticated = !!isLoggedIn;
1705    state.isAdmin = !!isAdmin;
1706
1707    // Always reset both auth presentations first so they remain mutually exclusive.
1708    if (els.loginSplit) {
1709      els.loginSplit.style.display = 'none';
1710      closeLoginMenu();
1711    } else if (els.loginBtn) {
1712      els.loginBtn.style.display = 'none';
1713    }
1714    if (els.userMenu) {
1715      els.userMenu.style.display = 'none';
1716      closeUserMenu();
1717    }
1718    if (els.userName) {
1719      els.userName.style.display = 'none';
1720      els.userName.textContent = '';
1721      els.userName.removeAttribute('aria-label');
1722      els.userName.setAttribute('href', '/admin#account');
1723      els.userName.classList.remove('active');
1724      els.userName.removeAttribute('aria-current');
1725    }
1726
1727    if (isLoggedIn) {
1728      // Default to hidden until server session-check confirms there are
1729      // additional active sessions beyond the current one.
1730      updateLogoutOtherSessionsUi(0);
1731      if (els.composeTools) {
1732        els.composeTools.style.display = isAdmin ? 'inline-flex' : 'none';
1733      } else if (els.composeLink) {
1734        els.composeLink.style.display = isAdmin ? 'inline-flex' : 'none';
1735      }
1736      if (els.userMenu) {
1737        if (els.menuPrimaryLink) {
1738          if (isAdmin) {
1739            els.menuPrimaryLink.textContent = 'Admin';
1740            els.menuPrimaryLink.href = '/admin';
1741          } else {
1742            els.menuPrimaryLink.textContent = 'Account';
1743            els.menuPrimaryLink.href = '/admin#account';
1744          }
1745        }
1746        els.userMenu.style.display = 'inline-flex';
1747      }
1748      if (els.userName) {
1749        els.userName.style.display = 'inline-block';
1750        els.userName.textContent = displayName || 'signed-in';
1751        els.userName.setAttribute('aria-label', 'Open account settings');
1752        els.userName.setAttribute('href', '/admin#account');
1753        updateUserNameActiveState();
1754      }
1755      syncVideoCallPresencePolling();
1756      syncSiteNotificationPolling();
1757      return;
1758    }
1759
1760    if (els.loginSplit) {
1761      els.loginSplit.style.display = 'inline-flex';
1762    } else if (els.loginBtn) {
1763      els.loginBtn.style.display = 'inline-block';
1764    }
1765    if (els.composeTools) {
1766      els.composeTools.style.display = 'none';
1767    } else if (els.composeLink) {
1768      els.composeLink.style.display = 'none';
1769    }
1770    updateLogoutOtherSessionsUi(0);
1771    syncPluginAuthUi();
1772    syncVideoCallPresencePolling();
1773    syncSiteNotificationPolling();
1774  }
1775
1776  function updateLogoutOtherSessionsUi(countRaw) {
1777    if (!els.menuLogoutEverywhereBtn) {
1778      return;
1779    }
1780    var count = Number(countRaw || 0);
1781    if (!isFinite(count) || count < 0) {
1782      count = 0;
1783    }
1784    if (count < 1) {
1785      els.menuLogoutEverywhereBtn.style.display = 'none';
1786      els.menuLogoutEverywhereBtn.textContent = 'Log out other sessions';
1787      if (els.menuLogoutMoreBtn) {
1788        els.menuLogoutMoreBtn.style.display = 'none';
1789        els.menuLogoutMoreBtn.setAttribute('aria-expanded', 'false');
1790      }
1791      closeLogoutSubmenu();
1792      return;
1793    }
1794    els.menuLogoutEverywhereBtn.style.display = 'block';
1795    els.menuLogoutEverywhereBtn.textContent = 'Log out other sessions (' + String(count) + ')';
1796    if (els.menuLogoutMoreBtn) {
1797      els.menuLogoutMoreBtn.style.display = 'inline-flex';
1798    }
1799  }
1800
1801  function checkAuth() {
1802    var token = String(getSessionToken() || '').trim();
1803    if (!hasStoredSessionToken()) {
1804      resetSessionCheckGrace();
1805      clearLocalStorageAuth();
1806      applyLoggedInUi(false, false, '');
1807      return Promise.resolve(false);
1808    }
1809
1810    return fetch('/cgi/ssh-auth-check-session?session_token=' + encodeURIComponent(token), { cache: 'no-store' })
1811      .then(function (res) { return res.json(); })
1812      .then(function (data) {
1813        if (!data || !data.authenticated) {
1814          if ((state.isAuthenticated || hasStoredNostrAuthHint()) && token) {
1815            if (sessionCheckGraceToken === token) {
1816              sessionCheckGraceCount += 1;
1817            } else {
1818              sessionCheckGraceToken = token;
1819              sessionCheckGraceCount = 1;
1820            }
1821            if (sessionCheckGraceCount < 2) {
1822              scheduleSessionCheckRetry(token);
1823              return !!state.isAuthenticated || hasStoredNostrAuthHint();
1824            }
1825          }
1826          resetSessionCheckGrace();
1827          clearLocalStorageAuth();
1828          applyLoggedInUi(false, false, '');
1829          return false;
1830        }
1831        resetSessionCheckGrace();
1832        if (data.csrf_token) {
1833          localStorage.setItem('csrf_token', data.csrf_token);
1834        }
1835        if (data.nostr_pubkey) {
1836          localStorage.setItem('last_auth_pubkey', data.nostr_pubkey);
1837        }
1838        if (data.session_auth_method) {
1839          localStorage.setItem('last_auth_method', data.session_auth_method);
1840        } else if (data.nostr_pubkey) {
1841          localStorage.setItem('last_auth_method', 'nostr');
1842        } else if (hasStoredNostrAuthHint()) {
1843          localStorage.setItem('last_auth_method', 'nostr');
1844        } else {
1845          localStorage.removeItem('last_auth_method');
1846        }
1847        if (data.player_name) {
1848          localStorage.setItem('last_auth_player_name', data.player_name);
1849        }
1850        if (typeof data.is_admin !== 'undefined') {
1851          localStorage.setItem('last_auth_is_admin', data.is_admin ? '1' : '0');
1852        }
1853        applyLoggedInUi(
1854          true,
1855          !!data.is_admin,
1856          data.player_name || localStorage.getItem('last_auth_player_name') || data.username || ''
1857        );
1858        updateLogoutOtherSessionsUi(data.other_sessions_count || 0);
1859        emitAuthChanged({
1860          authenticated: true,
1861          is_admin: !!data.is_admin,
1862          username: data.username || '',
1863          player_name: data.player_name || localStorage.getItem('last_auth_player_name') || data.username || '',
1864          other_sessions_count: data.other_sessions_count || 0
1865        });
1866        return true;
1867      })
1868      .catch(function (err) {
1869        if (err && String(err.name || '') === 'AbortError') {
1870          return !!state.isAuthenticated;
1871        }
1872        if (!state.isAuthenticated && !hasStoredSessionToken()) {
1873          applyLoggedInUi(false, false, '');
1874        }
1875        updateLogoutOtherSessionsUi(0);
1876        return !!state.isAuthenticated;
1877      });
1878  }
1879
1880  function verifySessionWithRetry(remainingAttempts, delayMs) {
1881    var attempts = Number(remainingAttempts || 0);
1882    if (!isFinite(attempts) || attempts < 1) {
1883      attempts = 1;
1884    }
1885    return checkAuth().then(function (ok) {
1886      if (ok) {
1887        return true;
1888      }
1889      if (attempts <= 1) {
1890        return false;
1891      }
1892      return waitMs(delayMs).then(function () {
1893        return verifySessionWithRetry(attempts - 1, delayMs);
1894      });
1895    });
1896  }
1897
1898  function finalizeLoginUiAfterSuccess(finishData) {
1899    return verifySessionWithRetry(6, 180).then(function (ok) {
1900      if (!ok) {
1901        clearLocalStorageAuth();
1902        applyLoggedInUi(false, false, '');
1903        throw new Error('Login was signed, but session validation failed. Please try again.');
1904      }
1905      hideAuthModal();
1906      return true;
1907    });
1908  }
1909
1910  function openUserMenu() {
1911    if (!els.menuPanel || !els.menuBtn) {
1912      return;
1913    }
1914    closeNavOverflowMenu();
1915    els.menuPanel.hidden = false;
1916    els.menuBtn.setAttribute('aria-expanded', 'true');
1917  }
1918
1919  function closeUserMenu() {
1920    if (!els.menuPanel || !els.menuBtn) {
1921      return;
1922    }
1923    closeLogoutSubmenu();
1924    els.menuPanel.hidden = true;
1925    els.menuBtn.setAttribute('aria-expanded', 'false');
1926  }
1927
1928  function openLogoutSubmenu() {
1929    if (!els.menuLogoutSubmenu || !els.menuLogoutMoreBtn) {
1930      return;
1931    }
1932    els.menuLogoutSubmenu.hidden = false;
1933    els.menuLogoutMoreBtn.setAttribute('aria-expanded', 'true');
1934  }
1935
1936  function closeLogoutSubmenu() {
1937    if (!els.menuLogoutSubmenu || !els.menuLogoutMoreBtn) {
1938      return;
1939    }
1940    els.menuLogoutSubmenu.hidden = true;
1941    els.menuLogoutMoreBtn.setAttribute('aria-expanded', 'false');
1942  }
1943
1944  function openLoginMenu() {
1945    if (!els.loginMenu || !els.loginMoreBtn) {
1946      return;
1947    }
1948    closeNavOverflowMenu();
1949    els.loginMenu.hidden = false;
1950    els.loginMoreBtn.setAttribute('aria-expanded', 'true');
1951  }
1952
1953  function closeLoginMenu() {
1954    if (!els.loginMenu || !els.loginMoreBtn) {
1955      return;
1956    }
1957    els.loginMenu.hidden = true;
1958    els.loginMoreBtn.setAttribute('aria-expanded', 'false');
1959  }
1960
1961  function hasDesktopSigner() {
1962    return !!(window.nostr && typeof window.nostr.signEvent === 'function');
1963  }
1964
1965  function waitForDesktopSigner(timeoutMs) {
1966    if (hasDesktopSigner()) {
1967      return Promise.resolve(true);
1968    }
1969    var startedAt = Date.now();
1970    var timeout = Number(timeoutMs || 1200);
1971    if (!isFinite(timeout) || timeout < 0) {
1972      timeout = 1200;
1973    }
1974    return new Promise(function (resolve) {
1975      function check() {
1976        if (hasDesktopSigner()) {
1977          resolve(true);
1978          return;
1979        }
1980        if (Date.now() - startedAt >= timeout) {
1981          resolve(false);
1982          return;
1983        }
1984        window.setTimeout(check, 50);
1985      }
1986      check();
1987    });
1988  }
1989
1990  function isMobileLikeRuntime() {
1991    var nav = typeof navigator === 'object' ? navigator : null;
1992    var ua = String((nav && nav.userAgent) || '');
1993    var uaData = nav && nav.userAgentData ? nav.userAgentData : null;
1994    if (uaData && uaData.mobile === true) {
1995      return true;
1996    }
1997    if (/Android|iPhone|iPad|iPod|Mobile/i.test(ua)) {
1998      return true;
1999    }
2000    var hasTouch = !!(nav && nav.maxTouchPoints && nav.maxTouchPoints > 0);
2001    var coarsePointer = false;
2002    var narrowViewport = false;
2003    if (typeof window.matchMedia === 'function') {
2004      coarsePointer = window.matchMedia('(pointer: coarse), (hover: none)').matches;
2005      narrowViewport = window.matchMedia('(max-width: 780px)').matches;
2006    }
2007    return hasTouch && (coarsePointer || narrowViewport);
2008  }
2009
2010  function detectedAuthPlatformFlavor() {
2011    var nav = typeof navigator === 'object' ? navigator : null;
2012    var ua = String((nav && nav.userAgent) || '');
2013    var platform = String((nav && nav.platform) || '');
2014    var uaData = nav && nav.userAgentData ? nav.userAgentData : null;
2015    var uaPlatform = String((uaData && uaData.platform) || '');
2016    var combined = [ua, platform, uaPlatform].join(' ');
2017    var touchPoints = Number((nav && nav.maxTouchPoints) || 0);
2018    if (/Android/i.test(combined)) {
2019      return 'android';
2020    }
2021    if (/iPhone|iPad|iPod/i.test(combined)) {
2022      return 'ios';
2023    }
2024    // iPadOS can present itself as desktop Safari on MacIntel.
2025    if (/Mac/i.test(platform) && touchPoints > 1) {
2026      return 'ios';
2027    }
2028    if (isMobileLikeRuntime()) {
2029      return 'remote';
2030    }
2031    return 'desktop';
2032  }
2033
2034  function preferredAuthInitialSelection() {
2035    var flavor = detectedAuthPlatformFlavor();
2036    if (flavor === 'android' || flavor === 'ios' || flavor === 'remote') {
2037      return { tab: 'phone', flavor: flavor };
2038    }
2039    return { tab: 'register', flavor: 'desktop' };
2040  }
2041
2042  function normalizeAuthInitialSelection(initialTab, initialFlavor) {
2043    var fallback = preferredAuthInitialSelection();
2044    if (initialTab && typeof initialTab === 'object') {
2045      return normalizeAuthInitialSelection(initialTab.tab || initialTab.route, initialTab.flavor);
2046    }
2047    var tab = String(initialTab || '').trim();
2048    var flavor = String(initialFlavor || '').trim();
2049    if (!tab || tab === 'auto') {
2050      return fallback;
2051    }
2052    if (tab === 'phone') {
2053      if (!flavor) {
2054        var detected = detectedAuthPlatformFlavor();
2055        flavor = (detected === 'android' || detected === 'ios') ? detected : 'remote';
2056      }
2057      return { tab: 'phone', flavor: flavor };
2058    }
2059    if (tab === 'manual') {
2060      return { tab: 'manual', flavor: 'manual' };
2061    }
2062    return { tab: 'register', flavor: 'desktop' };
2063  }
2064
2065  function preferredUnsignedLoginTab() {
2066    return preferredAuthInitialSelection();
2067  }
2068
2069  function startPrimaryLogin() {
2070    var options = arguments[0];
2071    var showFallbackModal = !(options && options.fallbackModal === false);
2072    closeLoginMenu();
2073    return waitForDesktopSigner(1200).then(function (available) {
2074      if (!available) {
2075        if (showFallbackModal) {
2076          showAuthModal(preferredUnsignedLoginTab());
2077        } else {
2078          showNavToast('No Nostr browser signer was found.', 'info', 4200);
2079        }
2080        return false;
2081      }
2082      return startDesktopSignerLogin(false, '');
2083    });
2084  }
2085
2086  function pageRequiresAuthorization() {
2087    var path = String(window.location.pathname || '').replace(/\/+$/, '') || '/';
2088    if (path === '/pages/admin.html' || path === '/pages/admin' || path === '/admin.html' || path === '/admin') {
2089      return true;
2090    }
2091    if (document.body && document.body.getAttribute('data-requires-auth') === 'true') {
2092      return true;
2093    }
2094    return false;
2095  }
2096
2097  function handlePostLogoutNavigation(toastMessage) {
2098    var message = String(toastMessage || 'Logged out.');
2099    if (pageRequiresAuthorization()) {
2100      rememberNavToast(message, 'info', 3800);
2101      window.location.assign('/');
2102      return;
2103    }
2104    if (els.authModal && !els.authModal.hidden) {
2105      hideAuthModal();
2106    }
2107    showNavToast(message, 'info', 3800);
2108  }
2109
2110  function logout() {
2111    var token = getSessionToken();
2112    if (!token) {
2113      clearLocalStorageAuth();
2114      return clearLocalKeyMaterial().finally(function () {
2115        applyLoggedInUi(false, false, '');
2116        handlePostLogoutNavigation('Logged out.');
2117      });
2118    }
2119
2120    var body = new URLSearchParams({
2121      session_token: token,
2122      csrf_token: getCsrfToken()
2123    });
2124
2125    return fetch('/cgi/ssh-auth-logout', {
2126      method: 'POST',
2127      cache: 'no-store',
2128      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
2129      body: body.toString()
2130    }).catch(function () {
2131      return null;
2132    }).finally(function () {
2133      clearLocalStorageAuth();
2134      return clearLocalKeyMaterial().finally(function () {
2135        applyLoggedInUi(false, false, '');
2136        handlePostLogoutNavigation('Logged out.');
2137      });
2138    });
2139  }
2140
2141  function buildNostrConnectUri(appPubkey, pairSecret, relays) {
2142    var metadata = {
2143      name: 'gazeta',
2144      url: window.location.origin,
2145      description: 'Sign in, vote on lists, and sign zaps for this blog.'
2146    };
2147    return window.citrine.nostr.buildNostrConnectUri({
2148      appPubkey: appPubkey,
2149      pairSecret: pairSecret,
2150      relays: relays,
2151      name: 'gazeta',
2152      metadata: metadata,
2153      perms: 'get_public_key,sign_event:22242,sign_event:9734,sign_event:7,sign_event:17,sign_event:5'
2154    });
2155  }
2156
2157  function saveNip46PairState() {
2158    if (!state.nip46.appSecretHex || !state.nip46.appPubkey) {
2159      return Promise.resolve();
2160    }
2161    return idbSet(KEY_NIP46_PAIR, {
2162      version: 2,
2163      domain: currentHost(),
2164      appSecretHex: state.nip46.appSecretHex,
2165      appPubkey: state.nip46.appPubkey,
2166      pairSecret: state.nip46.pairSecret,
2167      relays: state.nip46.relays,
2168      signerPubkey: normalizePubkeyHex(state.nip46.signerPubkey || ''),
2169      accountPubkey: normalizePubkeyHex(state.nip46.accountPubkey || ''),
2170      createdAt: nowEpoch()
2171    });
2172  }
2173
2174  function renderQrCode(value) {
2175    if (!els.authNip46Qr) {
2176      return;
2177    }
2178    els.authNip46Qr.innerHTML = '';
2179    if (typeof window.QRCode === 'function') {
2180      new window.QRCode(els.authNip46Qr, {
2181        text: value,
2182        width: 196,
2183        height: 196,
2184        colorDark: '#0f172a',
2185        colorLight: '#ffffff',
2186        correctLevel: window.QRCode.CorrectLevel.M
2187      });
2188      return;
2189    }
2190    var pre = document.createElement('pre');
2191    pre.textContent = value;
2192    els.authNip46Qr.appendChild(pre);
2193  }
2194
2195  function updateNip46PairingLink() {
2196    if (!state.nip46.appPubkey || !state.nip46.pairSecret) {
2197      return;
2198    }
2199    var uri = buildNostrConnectUri(state.nip46.appPubkey, state.nip46.pairSecret, state.nip46.relays);
2200    if (els.authNip46Uri) {
2201      els.authNip46Uri.textContent = uri;
2202    }
2203    if (els.authNip46Open) {
2204      els.authNip46Open.href = uri;
2205      els.authNip46Open.setAttribute('data-nip46-uri', uri);
2206      els.authNip46Open.textContent = 'Connect Nostr';
2207      els.authNip46Open.setAttribute('aria-label', 'Connect Nostr with a signer app');
2208      els.authNip46Open.setAttribute('title', 'Connect Nostr with a signer app');
2209    }
2210    renderQrCode(uri);
2211    updatePhoneContinueState();
2212  }
2213
2214  function currentNip46Uri() {
2215    if (!state.nip46.appPubkey || !state.nip46.pairSecret) {
2216      return '';
2217    }
2218    return buildNostrConnectUri(state.nip46.appPubkey, state.nip46.pairSecret, state.nip46.relays);
2219  }
2220
2221  function hasPendingNip46Requests() {
2222    return Object.keys(state.nip46.pending || {}).length > 0;
2223  }
2224
2225  function scheduleDeepLinkFallbackHint() {
2226    if (state.nip46.deepLinkFallbackTimer) {
2227      clearTimeout(state.nip46.deepLinkFallbackTimer);
2228      state.nip46.deepLinkFallbackTimer = 0;
2229    }
2230    state.nip46.deepLinkFallbackTimer = window.setTimeout(function () {
2231      state.nip46.deepLinkFallbackTimer = 0;
2232      if (!isPhonePairingPanelActive() || document.hidden || state.nip46.signerPubkey) {
2233        return;
2234      }
2235      setNip46Diagnostics('No signer app response yet. The copy button gives the same Nostr Connect link.', 'warn');
2236      setAuthMessage('No signer app response yet. Recommended Apps lists signer options, and the copy button gives a manual Nostr Connect link.', 'warn');
2237    }, 1800);
2238  }
2239
2240  function refreshPhoneSignerListenerAfterReturn(reason) {
2241    if (!state.nip46.active || !state.nip46.appPubkey || !isPhonePairingPanelActive()) {
2242      return;
2243    }
2244    if (state.nip46.returnRefreshTimer) {
2245      clearTimeout(state.nip46.returnRefreshTimer);
2246    }
2247    state.nip46.returnRefreshTimer = window.setTimeout(function () {
2248      state.nip46.returnRefreshTimer = 0;
2249      waitForNostrTools(4000).then(function () {
2250        var status = 'Listening again for the signer response.';
2251        if (hasPendingNip46Requests()) {
2252          status = 'Checking signer approval after return.';
2253        } else if (state.nip46.signerPubkey) {
2254          status = 'Signer connected. Waiting for login approval.';
2255        }
2256        ensureNip46Subscription(180, status);
2257        updateNip46PairingLink();
2258        if (state.nip46.signerPubkey && !hasPendingNip46Requests() && !state.nip46.autoLoginInFlight) {
2259          var currentTime = Date.now();
2260          if (!state.nip46.lastReturnLoginAttemptAt || currentTime - state.nip46.lastReturnLoginAttemptAt > 15000) {
2261            state.nip46.lastReturnLoginAttemptAt = currentTime;
2262            continuePhoneSignerLogin(true);
2263          }
2264        }
2265      }).catch(function (err) {
2266        setNip46Diagnostics((err && err.message) || 'Phone signer setup is still loading.', 'error');
2267      });
2268    }, reason === 'focus' ? 120 : 0);
2269  }
2270
2271  function openNativeDeepLink(uri, notReadyMessage) {
2272    var href = String(uri || '').trim();
2273    if (!href || href === '#') {
2274      setAuthMessage(notReadyMessage || 'Phone signer link is not ready yet.', 'error');
2275      return false;
2276    }
2277    try {
2278      setNip46Diagnostics('Opening signer app. Return here after approval.', 'info');
2279      setAuthMessage('Android may switch to the signer app. After approving pairing, return here for the login approval.', 'plain');
2280      scheduleDeepLinkFallbackHint();
2281      window.location.href = href;
2282      return true;
2283    } catch (_err) {
2284      setAuthMessage('This browser did not open the phone signer link. Copy link is available as a fallback.', 'error');
2285      return false;
2286    }
2287  }
2288
2289  function initNip46Pairing() {
2290    return waitForNostrTools(8500).then(function () {
2291      return initNip46PairingWithTools();
2292    });
2293  }
2294
2295  function initNip46PairingWithTools() {
2296    if (state.nip46.active) {
2297      updateNip46PairingLink();
2298      ensureNip46Subscription(180, state.nip46.signerPubkey ? '' : 'Waiting for signer');
2299      return Promise.resolve();
2300    }
2301
2302    return idbGet(KEY_NIP46_PAIR).then(function (saved) {
2303      var appSecretHex = '';
2304      var pairSecret = '';
2305      var relays = NIP46_RELAYS.slice();
2306      var savedRelays = [];
2307      var savedRelaysMatchDefaults = false;
2308
2309      if (saved && typeof saved === 'object' && saved.domain === currentHost()) {
2310        appSecretHex = String(saved.appSecretHex || '');
2311        pairSecret = String(saved.pairSecret || '');
2312        state.nip46.signerPubkey = normalizePubkeyHex(saved.signerPubkey || '');
2313        state.nip46.accountPubkey = normalizePubkeyHex(saved.accountPubkey || '');
2314        if (Array.isArray(saved.relays) && saved.relays.length) {
2315          savedRelays = saved.relays.map(function (item) { return String(item || '').trim(); }).filter(Boolean);
2316          savedRelaysMatchDefaults = savedRelays.length === NIP46_RELAYS.length && savedRelays.every(function (relay, idx) {
2317            return relay === NIP46_RELAYS[idx];
2318          });
2319          if (!savedRelaysMatchDefaults) {
2320            pairSecret = '';
2321          }
2322        }
2323      }
2324
2325      if (!appSecretHex) {
2326        appSecretHex = bytesToHex(window.NostrTools.generateSecretKey());
2327      }
2328      if (!pairSecret) {
2329        pairSecret = randomHex(16);
2330      }
2331
2332      var appPubkey = window.NostrTools.getPublicKey(hexToBytes(appSecretHex));
2333
2334      state.nip46.active = true;
2335      state.nip46.appSecretHex = appSecretHex;
2336      state.nip46.appPubkey = appPubkey;
2337      state.nip46.pairSecret = pairSecret;
2338      state.nip46.relays = relays;
2339      state.nip46.signerPubkey = normalizePubkeyHex(state.nip46.signerPubkey || '');
2340      state.nip46.accountPubkey = normalizePubkeyHex(state.nip46.accountPubkey || '');
2341      state.nip46.pool = new window.NostrTools.SimplePool();
2342      state.nip46.pending = {};
2343      state.nip46.pendingTimers = {};
2344      state.nip46.seenEvents = {};
2345      state.nip46.autoLoginInFlight = false;
2346      state.nip46.diagnostics = {
2347        eventsSeen: 0,
2348        decryptErrors: 0,
2349        ignoredSecrets: 0,
2350        lastEventPubkey: '',
2351        lastMessage: ''
2352      };
2353
2354      ensureNip46Subscription(30, 'Waiting for signer');
2355
2356      return saveNip46PairState();
2357    }).then(function () {
2358      updateNip46PairingLink();
2359    });
2360  }
2361
2362  function closeNip46Subscription() {
2363    if (state.nip46.subscription && typeof state.nip46.subscription.close === 'function') {
2364      state.nip46.subscription.close();
2365    }
2366    state.nip46.subscription = null;
2367  }
2368
2369  function ensureNip46Pool() {
2370    if (!state.nip46.pool) {
2371      state.nip46.pool = new window.NostrTools.SimplePool();
2372    }
2373  }
2374
2375  function ensureNip46Subscription(sinceSeconds, statusMessage) {
2376    if (!hasNostrTools() || !state.nip46.appPubkey) {
2377      return;
2378    }
2379    ensureNip46Pool();
2380    closeNip46Subscription();
2381    var lookback = Number(sinceSeconds || 30);
2382    if (!isFinite(lookback) || lookback < 30) {
2383      lookback = 30;
2384    }
2385    state.nip46.subscription = state.nip46.pool.subscribeMany(
2386      state.nip46.relays,
2387      [{ kinds: [NIP46_KIND], '#p': [state.nip46.appPubkey], since: nowEpoch() - lookback }],
2388      {
2389        onevent: function (event) {
2390          handleNip46RelayEvent(event);
2391        },
2392        oneose: function () {
2393          if (!state.nip46.signerPubkey) {
2394            setNip46Diagnostics(statusMessage || 'Waiting for signer', 'waiting');
2395          }
2396        },
2397        onclose: function () {
2398          if (isPhonePairingPanelActive() && !state.nip46.signerPubkey && state.nip46.diagnostics.eventsSeen > 0) {
2399            setNip46Diagnostics('Signer listener paused after relay activity. Returning to this panel refreshes it automatically.', 'warn');
2400          }
2401        }
2402      }
2403    );
2404    if (statusMessage) {
2405      setNip46Diagnostics(statusMessage, statusMessage === 'Waiting for signer' ? 'waiting' : 'info');
2406    }
2407  }
2408
2409  function resolveNip46Pending(id, payload, isError) {
2410    var entry = state.nip46.pending[id];
2411    if (!entry) {
2412      return;
2413    }
2414    delete state.nip46.pending[id];
2415    if (state.nip46.pendingTimers[id]) {
2416      clearTimeout(state.nip46.pendingTimers[id]);
2417      delete state.nip46.pendingTimers[id];
2418    }
2419    if (isError) {
2420      entry.reject(new Error(payload || 'NIP-46 request failed'));
2421    } else {
2422      entry.resolve(payload);
2423    }
2424  }
2425
2426  function extractConnectSecret(msg) {
2427    return window.citrine.nostr.extractConnectSecret(msg);
2428  }
2429
2430  function decryptNip46Content(event) {
2431    if (window.NostrTools.nip44 && typeof window.NostrTools.nip44.getConversationKey === 'function') {
2432      try {
2433        var conversationKey = window.NostrTools.nip44.getConversationKey(hexToBytes(state.nip46.appSecretHex), event.pubkey);
2434        return Promise.resolve(window.NostrTools.nip44.decrypt(event.content, conversationKey));
2435      } catch (_nip44Err) {
2436        // Older signers may still use the pre-NIP-44 Nostr Connect encryption path.
2437      }
2438    }
2439    return window.NostrTools.nip04.decrypt(hexToBytes(state.nip46.appSecretHex), event.pubkey, event.content);
2440  }
2441
2442  function encryptNip46Content(pubkey, plaintext) {
2443    if (window.NostrTools.nip44 && typeof window.NostrTools.nip44.getConversationKey === 'function') {
2444      var conversationKey = window.NostrTools.nip44.getConversationKey(hexToBytes(state.nip46.appSecretHex), pubkey);
2445      return Promise.resolve(window.NostrTools.nip44.encrypt(plaintext, conversationKey));
2446    }
2447    return window.NostrTools.nip04.encrypt(hexToBytes(state.nip46.appSecretHex), pubkey, plaintext);
2448  }
2449
2450  function continuePhoneSignerLogin(autoStarted) {
2451    if (state.nip46.autoLoginInFlight) {
2452      return Promise.resolve(false);
2453    }
2454    state.nip46.autoLoginInFlight = true;
2455    if (autoStarted) {
2456      setAuthMessage('Phone signer paired. Approve the sign-in request in the signer.', 'ok');
2457    } else {
2458      setAuthMessage('Starting phone signer login...', 'warn');
2459    }
2460    setAuthControlsDisabled(true);
2461    var loginPromise = loginWithPhoneSigner();
2462    if (!autoStarted && state.nip46.signerPubkey) {
2463      window.setTimeout(function () {
2464        openNativeDeepLink(currentNip46Uri(), 'Phone signer link is not ready yet.');
2465      }, 80);
2466    }
2467    return loginPromise.catch(function (err) {
2468      if (els.authPhoneBtn) {
2469        els.authPhoneBtn.hidden = false;
2470      }
2471      setAuthMessage(err.message || 'Phone signer login failed.', 'error');
2472      return false;
2473    }).finally(function () {
2474      state.nip46.autoLoginInFlight = false;
2475      setAuthControlsDisabled(false);
2476      updatePhoneContinueState();
2477    });
2478  }
2479
2480  function handleNip46RelayEvent(event) {
2481    if (!event || !event.id || state.nip46.seenEvents[event.id]) {
2482      return;
2483    }
2484    state.nip46.seenEvents[event.id] = true;
2485    state.nip46.diagnostics.eventsSeen += 1;
2486    state.nip46.diagnostics.lastEventPubkey = String(event.pubkey || '');
2487    setNip46Diagnostics('Signer responded. Checking...', 'info');
2488
2489    decryptNip46Content(event)
2490      .then(function (plain) {
2491        var msg = parseJsonResponse(plain);
2492
2493        if (msg && msg.method === 'connect') {
2494          var secret = extractConnectSecret(msg);
2495          if (secret && secret !== state.nip46.pairSecret) {
2496            state.nip46.diagnostics.ignoredSecrets += 1;
2497            setNip46Diagnostics('Saw an old signer response, but it belongs to a previous QR. Open the newest phone signer link.', 'warn');
2498            return;
2499          }
2500          state.nip46.signerPubkey = normalizePubkeyHex(event.pubkey || '');
2501          saveNip46PairState();
2502          updatePhoneContinueState();
2503          setNip46Diagnostics('Connected. Requesting approval.', 'ok');
2504          continuePhoneSignerLogin(true);
2505          return;
2506        }
2507
2508        if (extractConnectSecret(msg) === state.nip46.pairSecret) {
2509          state.nip46.signerPubkey = normalizePubkeyHex(event.pubkey || '');
2510          saveNip46PairState();
2511          updatePhoneContinueState();
2512          setNip46Diagnostics('Connected. Requesting approval.', 'ok');
2513          continuePhoneSignerLogin(true);
2514          return;
2515        }
2516
2517        if (msg && msg.id && state.nip46.pending[msg.id]) {
2518          if (msg.error) {
2519            resolveNip46Pending(msg.id, typeof msg.error === 'string' ? msg.error : JSON.stringify(msg.error), true);
2520            return;
2521          }
2522          resolveNip46Pending(msg.id, msg.result, false);
2523          return;
2524        }
2525        setNip46Diagnostics('Saw a Nostr Connect event, but it was not the pairing response for this page.', 'warn');
2526      })
2527      .catch(function () {
2528        state.nip46.diagnostics.decryptErrors += 1;
2529        setNip46Diagnostics('Saw a signer response, but the page could not decrypt it. Open the newest phone signer link from this panel.', 'error');
2530      });
2531  }
2532
2533  function sendNip46Rpc(method, params, timeoutMs) {
2534    if (!state.nip46.signerPubkey) {
2535      return Promise.reject(new Error('Phone signer is not paired yet. Scan QR first.'));
2536    }
2537
2538    var requestId = randomHex(12);
2539    var timeout = Number(timeoutMs || 60000);
2540    var rpc = {
2541      id: requestId,
2542      method: method,
2543      params: params || []
2544    };
2545
2546    return encryptNip46Content(state.nip46.signerPubkey, JSON.stringify(rpc)).then(function (ciphertext) {
2547      var eventTemplate = {
2548        kind: NIP46_KIND,
2549        created_at: nowEpoch(),
2550        tags: [['p', state.nip46.signerPubkey]],
2551        content: ciphertext
2552      };
2553      var signed = window.NostrTools.finalizeEvent(eventTemplate, hexToBytes(state.nip46.appSecretHex));
2554
2555      return new Promise(function (resolve, reject) {
2556        state.nip46.pending[requestId] = { resolve: resolve, reject: reject };
2557        state.nip46.pendingTimers[requestId] = setTimeout(function () {
2558          resolveNip46Pending(requestId, 'Phone signer timed out. Try again.', true);
2559        }, timeout);
2560
2561        state.nip46.pool.publish(state.nip46.relays, signed);
2562      });
2563    });
2564  }
2565
2566  function nip46SignEvent(template) {
2567    return sendNip46Rpc('sign_event', [JSON.stringify(template)], 70000)
2568      .then(function (result) {
2569        if (typeof result === 'string') {
2570          return parseJsonResponse(result);
2571        }
2572        return normalizeSignedEvent(result);
2573      });
2574  }
2575
2576  function getNip46AccountPubkey() {
2577    var cached = normalizePubkeyHex(state.nip46.accountPubkey || '');
2578    if (cached) {
2579      return Promise.resolve(cached);
2580    }
2581    return sendNip46Rpc('get_public_key', [], 30000).then(function (pubkey) {
2582      var normalized = normalizePubkeyHex(pubkey || '');
2583      if (!normalized) {
2584        throw new Error('Phone signer did not return a valid account pubkey.');
2585      }
2586      state.nip46.accountPubkey = normalized;
2587      return saveNip46PairState().then(function () {
2588        return normalized;
2589      });
2590    });
2591  }
2592
2593  function getNip46AccountPubkeyWithRetry(options) {
2594    var opts = options && typeof options === 'object' ? options : {};
2595    var delays = [1200, 2400, 4800];
2596
2597    if (opts.forceRefresh) {
2598      state.nip46.accountPubkey = '';
2599    }
2600
2601    return window.citrine.nostr.withTimedOutRetry(getNip46AccountPubkey, {
2602      delays: delays,
2603      wait: function (delayMs) {
2604        return new Promise(function (resolve) {
2605          window.setTimeout(resolve, delayMs);
2606        });
2607      },
2608      onRetry: function () {
2609        setNip46Diagnostics('Signer connected. Waiting for approval response.', 'waiting');
2610      }
2611    });
2612  }
2613
2614  function signInWithSigner(signEventFn, options) {
2615    var opts = options && typeof options === 'object' ? options : {};
2616    var getPubkeyFn = typeof opts.getPubkeyFn === 'function' ? opts.getPubkeyFn : null;
2617    var allowStoredPubkeyHint = opts.allowStoredPubkeyHint !== false;
2618    var pubkeyHint = normalizePubkeyHex(opts.pubkeyHint || '') || (allowStoredPubkeyHint ? normalizePubkeyHex(localStorage.getItem('last_auth_pubkey') || '') : '');
2619    var registerAttempt = !!opts.registerAttempt;
2620    var usernameHint = String(opts.usernameHint || '').trim();
2621    setAuthMessage('Creating a single-use login challenge...', 'warn');
2622    var pubkeyReady = pubkeyHint
2623      ? Promise.resolve(pubkeyHint)
2624      : (getPubkeyFn
2625        ? Promise.resolve(getPubkeyFn()).then(function (value) {
2626          return normalizePubkeyHex(value);
2627        }).catch(function () {
2628          return '';
2629        })
2630        : Promise.resolve(''));
2631    return pubkeyReady
2632      .then(function (resolvedPubkeyHint) {
2633        pubkeyHint = resolvedPubkeyHint || pubkeyHint;
2634        return beginChallenge(pubkeyHint || '');
2635      })
2636      .then(function (begin) {
2637        var authTemplate = authEventTemplate(begin.challenge, 'login', pubkeyHint);
2638        setAuthMessage('Sign the login challenge event...', 'warn');
2639        return requestSignerApproval(
2640          signEventFn,
2641          authTemplate,
2642          'Approve login in your signer',
2643          70000
2644        ).then(function (signedAuth) {
2645          var normalizedAuth = normalizeSignedEvent(signedAuth);
2646          var userPubkey = normalizePubkeyHex(signedEventPubkey(normalizedAuth));
2647          if (!userPubkey && pubkeyHint) {
2648            userPubkey = normalizePubkeyHex(pubkeyHint);
2649          }
2650          if (!userPubkey && getPubkeyFn) {
2651            return Promise.resolve(getPubkeyFn()).then(function (fallbackPubkey) {
2652              var fallback = normalizePubkeyHex(fallbackPubkey);
2653              return {
2654                begin: begin,
2655                userPubkey: fallback,
2656                signedAuth: normalizeSignedEventForSubmit(normalizedAuth, fallback, AUTH_KIND)
2657              };
2658            });
2659          }
2660          normalizedAuth = normalizeSignedEventForSubmit(normalizedAuth, userPubkey, AUTH_KIND);
2661          return {
2662            begin: begin,
2663            userPubkey: userPubkey,
2664            signedAuth: normalizedAuth
2665          };
2666        });
2667      })
2668      .then(function (payload) {
2669        if (!payload.userPubkey) {
2670          throw new Error('Signed auth event is missing pubkey.');
2671        }
2672        localStorage.setItem('last_auth_pubkey', payload.userPubkey);
2673        return finishLogin(
2674          payload.begin.request_id,
2675          payload.signedAuth,
2676          null,
2677          false,
2678          usernameHint,
2679          payload.userPubkey
2680        );
2681      })
2682      .then(function (finish) {
2683        rememberAuth(finish);
2684        return idbDelete(KEY_DEVICE_SESSION).then(function () {
2685          var created = !!(finish && (finish.account_created === true || finish.account_created === 'true'));
2686          if (registerAttempt && !created) {
2687            showNavToast('You were logged in because this account already exists.', 'ok', 4200);
2688          }
2689          return finalizeLoginUiAfterSuccess(finish);
2690        });
2691      });
2692  }
2693
2694  function loginWithNip07(options) {
2695    var signer = getBrowserSigner();
2696    var opts = options && typeof options === 'object' ? options : {};
2697    try {
2698      if (typeof window.focus === 'function') {
2699        window.focus();
2700      }
2701    } catch (_focusErr) {
2702      // noop
2703    }
2704    return signInWithSigner(
2705      function (template) {
2706        return Promise.resolve(signer.signEvent(template));
2707      },
2708      {
2709        getPubkeyFn: typeof signer.getPublicKey === 'function'
2710          ? function () { return Promise.resolve(signer.getPublicKey()); }
2711          : null,
2712        pubkeyHint: '',
2713        registerAttempt: !!opts.registerAttempt,
2714        usernameHint: String(opts.usernameHint || '').trim()
2715      }
2716    );
2717  }
2718
2719  function resetPhonePairingLink(options) {
2720    var opts = options && typeof options === 'object' ? options : {};
2721    var rotateAppKey = opts.rotateAppKey !== false;
2722    return waitForNostrTools(8500).then(function () {
2723      return idbGet(KEY_NIP46_PAIR);
2724    }).then(function (saved) {
2725      var nextSecret = '';
2726      if (!rotateAppKey) {
2727        nextSecret = saved && typeof saved === 'object' ? String(saved.appSecretHex || '') : '';
2728        if (!nextSecret) {
2729          nextSecret = state.nip46.appSecretHex || '';
2730        }
2731      }
2732      if (!nextSecret) {
2733        nextSecret = bytesToHex(window.NostrTools.generateSecretKey());
2734      }
2735      return idbSet(KEY_NIP46_PAIR, {
2736        version: 2,
2737        domain: currentHost(),
2738        appSecretHex: nextSecret,
2739        appPubkey: window.NostrTools.getPublicKey(hexToBytes(nextSecret)),
2740        pairSecret: randomHex(16),
2741        relays: NIP46_RELAYS.slice(),
2742        signerPubkey: '',
2743        accountPubkey: '',
2744        createdAt: nowEpoch()
2745      });
2746    }).then(function () {
2747      if (state.nip46.subscription && typeof state.nip46.subscription.close === 'function') {
2748        state.nip46.subscription.close();
2749      }
2750      state.nip46.subscription = null;
2751      if (state.nip46.pool && typeof state.nip46.pool.destroy === 'function') {
2752        state.nip46.pool.destroy();
2753      }
2754      state.nip46.pool = null;
2755      state.nip46.active = false;
2756      state.nip46.signerPubkey = '';
2757      state.nip46.accountPubkey = '';
2758      state.nip46.autoLoginInFlight = false;
2759      return initNip46Pairing();
2760    });
2761  }
2762
2763  function rotateUnpairedNip46StateNow() {
2764    if (!hasNostrTools() || state.nip46.signerPubkey) {
2765      return false;
2766    }
2767    closeNip46Subscription();
2768    if (state.nip46.pool && typeof state.nip46.pool.destroy === 'function') {
2769      state.nip46.pool.destroy();
2770    }
2771    var appSecretHex = bytesToHex(window.NostrTools.generateSecretKey());
2772    state.nip46.active = true;
2773    state.nip46.appSecretHex = appSecretHex;
2774    state.nip46.appPubkey = window.NostrTools.getPublicKey(hexToBytes(appSecretHex));
2775    state.nip46.pairSecret = randomHex(16);
2776    state.nip46.relays = NIP46_RELAYS.slice();
2777    state.nip46.signerPubkey = '';
2778    state.nip46.accountPubkey = '';
2779    state.nip46.pool = new window.NostrTools.SimplePool();
2780    state.nip46.pending = {};
2781    state.nip46.pendingTimers = {};
2782    state.nip46.seenEvents = {};
2783    state.nip46.autoLoginInFlight = false;
2784    state.nip46.diagnostics = {
2785      eventsSeen: 0,
2786      decryptErrors: 0,
2787      ignoredSecrets: 0,
2788      lastEventPubkey: '',
2789      lastMessage: ''
2790    };
2791    ensureNip46Subscription(30, 'Waiting for signer');
2792    updateNip46PairingLink();
2793    saveNip46PairState().catch(function () {
2794      // The in-memory fresh link remains valid even if persistence is unavailable.
2795    });
2796    return true;
2797  }
2798
2799  function refreshUnpairedNip46Link() {
2800    if (state.nip46.signerPubkey) {
2801      updateNip46PairingLink();
2802      return Promise.resolve();
2803    }
2804    setNip46Diagnostics('Making a fresh signer link.', 'info');
2805    return resetPhonePairingLink({ rotateAppKey: true });
2806  }
2807
2808  function startDesktopSignerLogin(registerAttempt, usernameHint) {
2809    var asRegister = !!registerAttempt;
2810    if (!hasDesktopSigner()) {
2811      return Promise.reject(new Error('No browser signer detected. Use phone signer QR or signed challenge login.'));
2812    }
2813    setAuthControlsDisabled(true);
2814    return loginWithNip07({
2815      registerAttempt: asRegister,
2816      usernameHint: String(usernameHint || '').trim()
2817    }).finally(function () {
2818      setAuthControlsDisabled(false);
2819    });
2820  }
2821
2822  function loginWithPhoneSigner() {
2823    return waitForNostrTools(8500)
2824      .then(function () {
2825        showPanel(els.authPhonePanel, true);
2826        showPanel(els.authManualPanel, false);
2827
2828        return initNip46Pairing()
2829          .then(function () {
2830            if (!state.nip46.signerPubkey) {
2831              throw new Error('Phone signer is not paired yet. Open the signer link or scan the QR first.');
2832            }
2833            return signInWithSigner(
2834              function (template) {
2835                return nip46SignEvent(template);
2836              },
2837              {
2838                getPubkeyFn: function () {
2839                  return getNip46AccountPubkeyWithRetry({ forceRefresh: true });
2840                },
2841                pubkeyHint: '',
2842                allowStoredPubkeyHint: false
2843              }
2844            );
2845          });
2846      });
2847  }
2848
2849  function prepareManualLogin() {
2850    setAuthMessage('Creating a single-use login challenge...', 'warn');
2851    showPanel(els.authManualPanel, true);
2852    showPanel(els.authPhonePanel, false);
2853
2854    return beginChallenge(localStorage.getItem('last_auth_pubkey') || '')
2855      .then(function (begin) {
2856        state.manualChallenge = begin;
2857
2858        if (els.authManualRequestId) {
2859          els.authManualRequestId.value = begin.request_id || '';
2860        }
2861        if (els.authManualChallenge) {
2862          els.authManualChallenge.value = begin.challenge || '';
2863        }
2864        if (els.authManualExpires) {
2865          els.authManualExpires.value = String(begin.expires_at || '');
2866        }
2867
2868        var authTemplate = authEventTemplate(begin.challenge, 'login', localStorage.getItem('last_auth_pubkey') || '');
2869        if (els.authManualTemplate) {
2870          els.authManualTemplate.value = JSON.stringify(authTemplate, null, 2);
2871        }
2872
2873        setAuthMessage('Challenge created. Sign the auth event and paste JSON below.', 'ok');
2874      });
2875  }
2876
2877  function submitManualLogin() {
2878    if (!state.manualChallenge || !state.manualChallenge.request_id) {
2879      return Promise.reject(new Error('Create a challenge first.'));
2880    }
2881
2882    var signedAuthRaw = els.authManualEvent ? String(els.authManualEvent.value || '').trim() : '';
2883    if (!signedAuthRaw) {
2884      return Promise.reject(new Error('Signed auth event JSON is required.'));
2885    }
2886
2887    var signedAuth;
2888    try {
2889      signedAuth = parseJsonResponse(signedAuthRaw);
2890    } catch (_) {
2891      throw new Error('Signed auth event JSON is invalid.');
2892    }
2893
2894    return finishLogin(
2895      state.manualChallenge.request_id,
2896      signedAuth,
2897      null,
2898      false
2899    ).then(function (finish) {
2900      rememberAuth(finish);
2901      return idbDelete(KEY_DEVICE_SESSION).then(function () {
2902        return finalizeLoginUiAfterSuccess(finish);
2903      });
2904    });
2905  }
2906
2907  function revokeEverywhereWithSigner(signEventFn) {
2908    var token = getSessionToken();
2909    var csrf = getCsrfToken();
2910    if (!token || !csrf) {
2911      return Promise.reject(new Error('You are not currently signed in.'));
2912    }
2913
2914    return postForm('/cgi/nostr-auth-revoke-all-begin', {
2915      session_token: token,
2916      csrf_token: csrf
2917    }).then(function (begin) {
2918      if (!begin || !begin.success) {
2919        throw new Error((begin && begin.error) || 'Unable to start revocation challenge.');
2920      }
2921
2922      var revokeTemplate = authEventTemplate(begin.challenge, 'revoke_all', begin.pubkey || '');
2923      return Promise.resolve(signEventFn(revokeTemplate))
2924        .then(function (signed) {
2925          return postForm('/cgi/nostr-auth-revoke-all-finish', {
2926            session_token: token,
2927            csrf_token: csrf,
2928            request_id: begin.request_id,
2929            event_json_b64: encodeBase64Utf8(JSON.stringify(normalizeSignedEvent(signed)))
2930          });
2931        });
2932    }).then(function (finish) {
2933      if (!finish || !finish.success) {
2934        throw new Error((finish && finish.error) || 'Revocation failed.');
2935      }
2936      setAuthMessage('All active delegated sessions were revoked.', 'ok');
2937      clearLocalStorageAuth();
2938      return clearLocalKeyMaterial().finally(function () {
2939        applyLoggedInUi(false, false, '');
2940        handlePostLogoutNavigation('Logged out other sessions.');
2941      });
2942    });
2943  }
2944
2945  function logoutEverywhere() {
2946    if (typeof window.nostr !== 'undefined' && window.nostr && typeof window.nostr.signEvent === 'function') {
2947      return revokeEverywhereWithSigner(function (template) {
2948        return Promise.resolve(window.nostr.signEvent(template));
2949      });
2950    }
2951
2952    if (state.nip46.signerPubkey) {
2953      return revokeEverywhereWithSigner(function (template) {
2954        return nip46SignEvent(template);
2955      });
2956    }
2957
2958    return Promise.reject(new Error('Fresh signer approval is required. Use Login or the phone signer flow first.'));
2959  }
2960
2961  function sharedSignerUnavailableMessage() {
2962    return 'No Nostr signer detected. Use a browser signer or pair a phone signer from Sign In.';
2963  }
2964
2965  function resolveSharedNostrSigner(opts) {
2966    var options = opts && typeof opts === 'object' ? opts : {};
2967    return window.citrine.nostr.createSharedNostrSigner({
2968      target: window,
2969      unavailableMessage: sharedSignerUnavailableMessage(),
2970      getLastPubkey: function () {
2971        return localStorage.getItem('last_auth_pubkey') || '';
2972      },
2973      nip46Client: {
2974        state: function () {
2975          return state.nip46;
2976        },
2977        ensurePairing: initNip46Pairing,
2978        sendRpc: sendNip46Rpc,
2979        getAccountPubkey: getNip46AccountPubkey
2980      }
2981    }).resolve(options);
2982  }
2983
2984  function createBlogNostrSigner() {
2985    return window.citrine.nostr.createSharedNostrSigner({
2986      target: window,
2987      unavailableMessage: sharedSignerUnavailableMessage(),
2988      getLastPubkey: function () {
2989        return localStorage.getItem('last_auth_pubkey') || '';
2990      },
2991      nip46Client: {
2992        state: function () {
2993          return state.nip46;
2994        },
2995        ensurePairing: initNip46Pairing,
2996        sendRpc: sendNip46Rpc,
2997        getAccountPubkey: getNip46AccountPubkey
2998      }
2999    });
3000  }
3001
3002  function exposeSharedNostrSigner() {
3003    if (window.blogNostrSigner && window.blogNostrSigner.__wizardryShared === true) {
3004      return;
3005    }
3006    window.blogNostrSigner = createBlogNostrSigner();
3007  }
3008
3009  exposeSharedNostrSigner();
3010
3011  function isAccountAreaPath() {
3012    var path = String(window.location.pathname || '').replace(/\/+$/, '') || '/';
3013    return path === '/pages/admin.html' ||
3014      path === '/pages/admin' ||
3015      path === '/admin.html' ||
3016      path === '/admin';
3017  }
3018
3019  function updateUserNameActiveState() {
3020    if (!els.userName) {
3021      return;
3022    }
3023    var active = isAccountAreaPath();
3024    els.userName.classList.toggle('active', active);
3025    if (active) {
3026      els.userName.setAttribute('aria-current', 'page');
3027    } else {
3028      els.userName.removeAttribute('aria-current');
3029    }
3030  }
3031
3032  function escapeHtml(text) {
3033    return String(text || '')
3034      .replace(/&/g, '&amp;')
3035      .replace(/</g, '&lt;')
3036      .replace(/>/g, '&gt;')
3037      .replace(/"/g, '&quot;')
3038      .replace(/'/g, '&#39;');
3039  }
3040
3041  function closeNavOverflowMenu() {
3042    if (els.navOverflowPanel) {
3043      els.navOverflowPanel.hidden = true;
3044      els.navOverflowPanel.classList.remove('is-viewport-positioned');
3045      els.navOverflowPanel.style.left = '';
3046      els.navOverflowPanel.style.top = '';
3047      els.navOverflowPanel.style.maxWidth = '';
3048    }
3049    if (els.navOverflowBtn) {
3050      els.navOverflowBtn.setAttribute('aria-expanded', 'false');
3051    }
3052  }
3053
3054  function clampNavOverflowPanelToViewport() {
3055    if (!els.navOverflowPanel || !els.navOverflowBtn) {
3056      return;
3057    }
3058    var gap = 8;
3059    var triggerRect = els.navOverflowBtn.getBoundingClientRect();
3060    var panelRect = els.navOverflowPanel.getBoundingClientRect();
3061    var viewportWidth = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0);
3062    var panelWidth = Math.min(panelRect.width || 176, Math.max(160, viewportWidth - (gap * 2)));
3063    var left = triggerRect.left;
3064    var maxLeft = viewportWidth - panelWidth - gap;
3065    if (left > maxLeft) {
3066      left = maxLeft;
3067    }
3068    if (left < gap) {
3069      left = gap;
3070    }
3071    els.navOverflowPanel.classList.add('is-viewport-positioned');
3072    els.navOverflowPanel.style.maxWidth = String(panelWidth) + 'px';
3073    els.navOverflowPanel.style.left = String(Math.round(left)) + 'px';
3074    els.navOverflowPanel.style.top = String(Math.round(triggerRect.bottom + 6)) + 'px';
3075  }
3076
3077  function openNavOverflowMenu() {
3078    if (!els.navOverflowPanel || !els.navOverflowBtn || !els.navOverflowMenu || els.navOverflowMenu.hidden) {
3079      return;
3080    }
3081    els.navOverflowPanel.hidden = false;
3082    clampNavOverflowPanelToViewport();
3083    els.navOverflowBtn.setAttribute('aria-expanded', 'true');
3084  }
3085
3086  function setNavOverflowButtonCount(hiddenCount) {
3087    if (!els.navOverflowBtn) {
3088      return;
3089    }
3090    var count = Number(hiddenCount || 0);
3091    if (!isFinite(count) || count < 0) {
3092      count = 0;
3093    }
3094    if (els.navOverflowCount) {
3095      if (count > 0) {
3096        els.navOverflowCount.textContent = String(count);
3097        els.navOverflowCount.hidden = false;
3098      } else {
3099        els.navOverflowCount.textContent = '0';
3100        els.navOverflowCount.hidden = true;
3101      }
3102    }
3103    if (count > 0) {
3104      els.navOverflowBtn.setAttribute('aria-label', 'More pages (' + String(count) + ' hidden)');
3105    } else {
3106      els.navOverflowBtn.setAttribute('aria-label', 'More pages');
3107    }
3108  }
3109
3110  function markNavOverflowReady() {
3111    if (state.navOverflowReady) {
3112      return;
3113    }
3114    state.navOverflowReady = true;
3115    markHydrationNavReady();
3116  }
3117
3118  function syncNavOverflowMenuNow() {
3119    if (state.navOverflowReady) {
3120      return;
3121    }
3122    var navCenter = document.querySelector('.nav-center');
3123    var navRight = document.querySelector('.nav-right');
3124    if (!navCenter || !navRight || !els.navOverflowMenu || !els.navOverflowBtn || !els.navOverflowPanel) {
3125      markNavOverflowReady();
3126      return;
3127    }
3128
3129    var links = Array.prototype.slice.call(navCenter.querySelectorAll('a[data-page]'));
3130    closeNavOverflowMenu();
3131    els.navOverflowPanel.innerHTML = '';
3132    var useMobileOverflowTier = !!(window.matchMedia && window.matchMedia('(max-width: 780px)').matches);
3133    if (useMobileOverflowTier) {
3134      links.forEach(function (link) {
3135        var rank = Number(link.getAttribute('data-nav-rank') || 0);
3136        if (rank >= 6) {
3137          link.classList.add('is-nav-overflow-hidden');
3138        }
3139      });
3140    }
3141    if (links.length < 2) {
3142      links.forEach(function (link) {
3143        link.classList.remove('is-nav-overflow-hidden');
3144      });
3145      els.navOverflowMenu.hidden = true;
3146      setNavOverflowButtonCount(0);
3147      markNavOverflowReady();
3148      return;
3149    }
3150
3151    function rectVisible(rect) {
3152      return !!rect && rect.width > 0 && rect.height > 0;
3153    }
3154
3155    function rectsVerticallyOverlap(a, b) {
3156      return !!(a && b && a.bottom > b.top && b.bottom > a.top);
3157    }
3158
3159    function rightmostVisibleLinkRect() {
3160      for (var idx = links.length - 1; idx >= 0; idx -= 1) {
3161        var link = links[idx];
3162        if (link.classList.contains('is-nav-overflow-hidden')) {
3163          continue;
3164        }
3165        var rect = link.getBoundingClientRect();
3166        if (rectVisible(rect)) {
3167          return rect;
3168        }
3169      }
3170      return null;
3171    }
3172
3173    function navCollisionLeft() {
3174      var search = navRight.querySelector('.nav-search');
3175      var target = search && !search.hidden ? search : navRight;
3176      var rect = target.getBoundingClientRect();
3177      if (!rectVisible(rect)) {
3178        return null;
3179      }
3180      return rect.left;
3181    }
3182
3183    function hasRightSideCollision() {
3184      var linkRect = rightmostVisibleLinkRect();
3185      var collisionLeft = navCollisionLeft();
3186      if (!linkRect || collisionLeft === null) {
3187        return false;
3188      }
3189      var centerRect = navCenter.getBoundingClientRect();
3190      var rightRect = navRight.getBoundingClientRect();
3191      if (!rectsVerticallyOverlap(centerRect, rightRect)) {
3192        return false;
3193      }
3194      var gutter = 8;
3195      return linkRect.right > (collisionLeft - gutter);
3196    }
3197
3198    function hasNavPressure() {
3199      var widthPressure = navCenter.scrollWidth > (navCenter.clientWidth + 1);
3200      var overlapPressure = hasRightSideCollision();
3201      return widthPressure || overlapPressure;
3202    }
3203
3204    var activeLink = links.find(function (link) { return link.classList.contains('active'); }) || null;
3205    var guard = 0;
3206
3207    while (hasNavPressure() && guard < links.length) {
3208      var hideCandidate = null;
3209      for (var i = links.length - 1; i >= 0; i -= 1) {
3210        var visibleLink = links[i];
3211        if (visibleLink.classList.contains('is-nav-overflow-hidden')) {
3212          continue;
3213        }
3214        if (activeLink && visibleLink === activeLink) {
3215          continue;
3216        }
3217        hideCandidate = visibleLink;
3218        break;
3219      }
3220      if (!hideCandidate && activeLink && !activeLink.classList.contains('is-nav-overflow-hidden')) {
3221        hideCandidate = activeLink;
3222      }
3223      if (hideCandidate && !hideCandidate.classList.contains('is-nav-overflow-hidden')) {
3224        hideCandidate.classList.add('is-nav-overflow-hidden');
3225        guard += 1;
3226        continue;
3227      }
3228      break;
3229    }
3230
3231    if (!useMobileOverflowTier && !hasNavPressure()) {
3232      var hiddenInOrder = links.filter(function (link) {
3233        return link.classList.contains('is-nav-overflow-hidden');
3234      });
3235      while (hiddenInOrder.length) {
3236        var revealCandidate = hiddenInOrder[0];
3237        revealCandidate.classList.remove('is-nav-overflow-hidden');
3238        if (hasNavPressure()) {
3239          revealCandidate.classList.add('is-nav-overflow-hidden');
3240          break;
3241        } else {
3242          hiddenInOrder.shift();
3243        }
3244      }
3245    }
3246
3247    var hiddenLinks = links.filter(function (link) {
3248      return link.classList.contains('is-nav-overflow-hidden');
3249    });
3250    if (!hiddenLinks.length) {
3251      els.navOverflowMenu.hidden = true;
3252      setNavOverflowButtonCount(0);
3253      markNavOverflowReady();
3254      return;
3255    }
3256
3257    hiddenLinks.forEach(function (link) {
3258      var item = document.createElement('a');
3259      item.className = 'nav-menu-item';
3260      item.setAttribute('role', 'menuitem');
3261      item.href = link.getAttribute('href') || '/';
3262      item.textContent = compact(link.textContent || '') || 'Page';
3263      item.addEventListener('click', function () {
3264        closeNavOverflowMenu();
3265      });
3266      els.navOverflowPanel.appendChild(item);
3267    });
3268
3269    setNavOverflowButtonCount(hiddenLinks.length);
3270    els.navOverflowMenu.hidden = false;
3271    markNavOverflowReady();
3272  }
3273
3274  function placeNavOverflowMenuWithPageLinks() {
3275    if (!els.navOverflowMenu) {
3276      return;
3277    }
3278    var nav = document.querySelector('nav.site-nav');
3279    if (!nav) {
3280      return;
3281    }
3282    var navCenter = nav.querySelector('.nav-center');
3283    if (!navCenter) {
3284      if (nav.lastElementChild === els.navOverflowMenu) {
3285        return;
3286      }
3287      nav.appendChild(els.navOverflowMenu);
3288      return;
3289    }
3290    if (els.navOverflowMenu.parentNode === navCenter && els.navOverflowMenu.nextElementSibling === null) {
3291      return;
3292    }
3293    navCenter.appendChild(els.navOverflowMenu);
3294  }
3295
3296  function scheduleNavOverflowMenuSync() {
3297    if (state.navOverflowTimer) {
3298      window.clearTimeout(state.navOverflowTimer);
3299      state.navOverflowTimer = 0;
3300    }
3301    if (state.navOverflowRaf && typeof window.cancelAnimationFrame === 'function') {
3302      window.cancelAnimationFrame(state.navOverflowRaf);
3303      state.navOverflowRaf = 0;
3304    }
3305    state.navOverflowTimer = window.setTimeout(function () {
3306      state.navOverflowTimer = 0;
3307      if (typeof window.requestAnimationFrame === 'function') {
3308        state.navOverflowRaf = window.requestAnimationFrame(function () {
3309          state.navOverflowRaf = 0;
3310          syncNavOverflowMenuNow();
3311        });
3312        return;
3313      }
3314      syncNavOverflowMenuNow();
3315    }, 60);
3316  }
3317
3318  function markHydrationNavReady() {
3319    var gate = window.__wizardryHydration;
3320    if (gate && typeof gate.markNavReady === 'function') {
3321      gate.markNavReady();
3322    }
3323  }
3324
3325  function applyInitialHighlightInSyncWithContent() {
3326    highlightCurrentPage();
3327  }
3328
3329  function navbarMatchesRows(pageRows) {
3330    var navCenter = document.querySelector('.nav-center');
3331    if (!navCenter) {
3332      return false;
3333    }
3334    var links = Array.prototype.slice.call(navCenter.querySelectorAll('a[data-page]'));
3335    var rows = (Array.isArray(pageRows) ? pageRows : []).filter(function (page) {
3336      return !!(page && String(page.slug || '').trim() && String(page.path || '').trim());
3337    });
3338    if (links.length !== rows.length) {
3339      return false;
3340    }
3341    for (var i = 0; i < rows.length; i += 1) {
3342      var link = links[i];
3343      var row = rows[i];
3344      if (String(link.getAttribute('data-page') || '') !== String(row.slug || '').trim()) {
3345        return false;
3346      }
3347      if (normalizeNavPath(link.getAttribute('href') || '') !== normalizeNavPath(String(row.path || '').trim())) {
3348        return false;
3349      }
3350      if (compact(link.textContent || '') !== compact(String(row.title || row.slug || ''))) {
3351        return false;
3352      }
3353      if (String(link.getAttribute('data-nav-rank') || '') !== String(i + 1)) {
3354        return false;
3355      }
3356    }
3357    return true;
3358  }
3359
3360  function renderNavbarNostrPages(pageRows, options) {
3361    var opts = options && typeof options === 'object' ? options : {};
3362    var navCenter = document.querySelector('.nav-center');
3363    if (!navCenter) {
3364      return;
3365    }
3366    if (navbarMatchesRows(pageRows)) {
3367      placeNavOverflowMenuWithPageLinks();
3368      syncNavSiteSignatureDestination();
3369      highlightCurrentPage();
3370      markNavOverflowReady();
3371      return;
3372    }
3373    if (state.navOverflowReady && opts.force !== true) {
3374      markNavOverflowReady();
3375      return;
3376    }
3377    var normalizedCurrent = normalizeNavPath(window.location.pathname);
3378    var html = '';
3379    var seen = {};
3380    (Array.isArray(pageRows) ? pageRows : []).forEach(function (page, index) {
3381      var slug = String(page && page.slug || '').trim();
3382      var title = String(page && page.title || '').trim();
3383      var path = String(page && page.path || '').trim();
3384      if (!slug || !path || seen[slug]) {
3385        return;
3386      }
3387      seen[slug] = true;
3388      var isActive = normalizeNavPath(path) === normalizedCurrent;
3389      html += '<a href="' + escapeHtml(path) + '" data-page="' + escapeHtml(slug) + '" data-nav-rank="' + String(index + 1) + '"' + (isActive ? ' class="active" aria-current="page"' : '') + '>' + escapeHtml(title || slug) + '</a>';
3390    });
3391    if (html) {
3392      navCenter.innerHTML = html;
3393    }
3394    placeNavOverflowMenuWithPageLinks();
3395    syncNavSiteSignatureDestination();
3396    highlightCurrentPage();
3397    syncNavOverflowMenuNow();
3398  }
3399
3400  function loadNavbarNostrPages(options) {
3401    var opts = options && typeof options === 'object' ? options : {};
3402    var navCenter = document.querySelector('.nav-center');
3403    if (!navCenter) {
3404      return Promise.resolve();
3405    }
3406
3407    var bootstrapPages = opts.useBootstrap === false ? null : readBootstrapNavbarPages();
3408    if (bootstrapPages) {
3409      try {
3410        localStorage.setItem('cached_navbar_pages_v1', JSON.stringify(bootstrapPages));
3411      } catch (_bootstrapCacheErr) {
3412        // Ignore storage failures.
3413      }
3414      renderNavbarNostrPages(bootstrapPages);
3415      if (!hasStoredSessionToken()) {
3416        warmNavbarNostrPagePrefetch();
3417      }
3418      return Promise.resolve();
3419    }
3420
3421    try {
3422      var cachedRaw = localStorage.getItem('cached_navbar_pages_v1') || '';
3423      if (cachedRaw) {
3424        var cachedPages = JSON.parse(cachedRaw);
3425        if (Array.isArray(cachedPages) && cachedPages.length) {
3426          renderNavbarNostrPages(cachedPages);
3427          if (!hasStoredSessionToken()) {
3428            warmNavbarNostrPagePrefetch();
3429          }
3430        }
3431      }
3432    } catch (_cacheReadErr) {
3433      // Ignore invalid cache.
3434    }
3435
3436    return fetch('/cgi/blog-list-navbar-pages', { cache: 'no-store' })
3437      .then(function (res) { return res.json(); })
3438      .then(function (data) {
3439        if (!data || !data.success || !Array.isArray(data.pages)) {
3440          return;
3441        }
3442        try {
3443          localStorage.setItem('cached_navbar_pages_v1', JSON.stringify(data.pages));
3444        } catch (_cacheErr) {
3445          // Ignore storage failures.
3446        }
3447        renderNavbarNostrPages(data.pages);
3448        if (!hasStoredSessionToken()) {
3449          warmNavbarNostrPagePrefetch();
3450        }
3451      })
3452      .catch(function () {
3453        // Keep static nav links on fetch failure.
3454      });
3455  }
3456
3457  window.addEventListener('wizardry-navbar-refresh-request', function (event) {
3458    var detail = event && event.detail ? event.detail : null;
3459    if (detail && Array.isArray(detail.pages)) {
3460      try {
3461        localStorage.setItem('cached_navbar_pages_v1', JSON.stringify(detail.pages));
3462      } catch (_cacheErr) {
3463        // Ignore storage failures.
3464      }
3465      renderNavbarNostrPages(detail.pages, { force: true });
3466      if (detail.skipFetch === true) {
3467        return;
3468      }
3469    }
3470    loadNavbarNostrPages({ useBootstrap: false }).catch(function () {
3471      // Keep current navbar when refresh fails.
3472    });
3473  });
3474
3475  function normalizeNavPath(path) {
3476    var p = String(path || '').trim();
3477    var search = '';
3478    if (!p) {
3479      return '/';
3480    }
3481    if (p.indexOf('http://') === 0 || p.indexOf('https://') === 0) {
3482      try {
3483        var parsed = new URL(p, window.location.href);
3484        p = parsed.pathname || '/';
3485        search = parsed.search || '';
3486      } catch (_err) {
3487        p = '/';
3488        search = '';
3489      }
3490    }
3491    if (!search && p.indexOf('?') >= 0) {
3492      var split = p.split('?', 2);
3493      p = split[0] || '/';
3494      search = split[1] ? ('?' + split[1]) : '';
3495    }
3496    p = p.replace(/\/+$/, '');
3497    if (!p) {
3498      p = '/';
3499    }
3500    if (p === '/pages/index' || p === '/pages/index.html') {
3501      try {
3502        var params = new URLSearchParams(search || '');
3503        var slug = String(params.get('page_slug') || params.get('slug') || '').trim().toLowerCase();
3504        slug = slug.replace(/[^a-z0-9-]+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '');
3505        if (slug && slug !== 'index') {
3506          return '/' + slug;
3507        }
3508      } catch (_err2) {
3509        // Ignore malformed page query strings.
3510      }
3511    }
3512    if (p === '/pages/index' || p === '/pages/index.html') {
3513      return '/';
3514    }
3515    if (p.indexOf('/pages/') === 0) {
3516      p = '/' + p.slice('/pages/'.length);
3517      p = p.replace(/\.html?$/i, '');
3518    }
3519    if (!p) {
3520      p = '/';
3521    }
3522    return p;
3523  }
3524
3525  function highlightCurrentPage() {
3526    var currentPath = window.location.href;
3527    var currentHash = window.location.hash || '';
3528    var navLinks = document.querySelectorAll('.nav-center a[data-page]');
3529    var normalizedCurrent = normalizeNavPath(currentPath);
3530    var navTargetPath = normalizedCurrent;
3531    var isBlogPostRoute = normalizedCurrent.indexOf('/posts/') === 0 ||
3532      normalizedCurrent === '/cgi/blog-open-post' ||
3533      normalizedCurrent.indexOf('/cgi/blog-open-post/') === 0;
3534    if (isBlogPostRoute) {
3535      navTargetPath = '/';
3536    }
3537    var matches = [];
3538    var navCenter = document.querySelector('.nav-center');
3539
3540    navLinks.forEach(function (link) {
3541      var href = link.getAttribute('href') || '';
3542      if (normalizeNavPath(href) === navTargetPath) {
3543        matches.push(link);
3544      }
3545    });
3546
3547    if (navCenter) {
3548      var existingTemp = navCenter.querySelector('a[data-temp-nav-current="true"]');
3549      if (existingTemp) {
3550        existingTemp.parentNode.removeChild(existingTemp);
3551      }
3552      navLinks = document.querySelectorAll('.nav-center a[data-page]');
3553    }
3554
3555    var selectedMatch = matches.length ? matches[0] : null;
3556    navLinks.forEach(function (link) {
3557      var active = link === selectedMatch;
3558      link.classList.toggle('active', active);
3559      link.classList.remove('allow-active-click');
3560      if (active) {
3561        link.setAttribute('aria-current', 'page');
3562        link.removeAttribute('aria-disabled');
3563        link.removeAttribute('tabindex');
3564      } else {
3565        link.removeAttribute('aria-current');
3566        link.removeAttribute('aria-disabled');
3567        link.removeAttribute('tabindex');
3568      }
3569    });
3570
3571    if (els.composeLink) {
3572      var normalizedComposePath = normalizeNavPath(currentPath);
3573      var onCompose = (normalizedComposePath === '/admin') && currentHash === '#compose';
3574      els.composeLink.classList.toggle('active', onCompose);
3575      els.composeLink.setAttribute('aria-disabled', onCompose ? 'true' : 'false');
3576      if (onCompose) {
3577        els.composeLink.setAttribute('tabindex', '-1');
3578      } else {
3579        els.composeLink.removeAttribute('tabindex');
3580      }
3581    }
3582    updateUserNameActiveState();
3583  }
3584
3585  function updateThemeSelect() {
3586    var themeSelect = document.getElementById('theme-select');
3587    if (themeSelect) {
3588      themeSelect.value = state.currentTheme;
3589    }
3590  }
3591
3592  function pulseThemeSwitchVisualState() {
3593    var root = document.documentElement;
3594    var body = document.body;
3595    if (root) {
3596      root.classList.add('theme-switching');
3597    }
3598    if (body) {
3599      body.classList.add('theme-switching');
3600    }
3601    if (themeSwitchVisualTimer) {
3602      clearTimeout(themeSwitchVisualTimer);
3603    }
3604    themeSwitchVisualTimer = setTimeout(function () {
3605      if (root) {
3606        root.classList.remove('theme-switching');
3607      }
3608      if (body) {
3609        body.classList.remove('theme-switching');
3610      }
3611      themeSwitchVisualTimer = null;
3612    }, 90);
3613  }
3614
3615  function swapThemeStylesheet(href) {
3616    var themeLink = document.getElementById('theme-stylesheet');
3617    if (!themeLink || !href) {
3618      return Promise.resolve();
3619    }
3620    var absoluteHref = href;
3621    try {
3622      absoluteHref = new URL(href, window.location.href).href;
3623    } catch (_err) {
3624      absoluteHref = href;
3625    }
3626    var currentHref = String(themeLink.href || '');
3627    var currentRequested = String(themeLink.getAttribute('data-theme-href') || '');
3628    if (currentHref === absoluteHref || currentRequested === href || currentRequested === absoluteHref) {
3629      return Promise.resolve();
3630    }
3631
3632    var token = ++themeSwapToken;
3633    return new Promise(function (resolve) {
3634      var preloader = document.createElement('link');
3635      preloader.rel = 'stylesheet';
3636      preloader.href = href;
3637      preloader.media = 'not all';
3638      preloader.setAttribute('data-theme-preload', 'true');
3639
3640      function cleanup() {
3641        if (preloader.parentNode) {
3642          preloader.parentNode.removeChild(preloader);
3643        }
3644      }
3645
3646      function commit() {
3647        if (token !== themeSwapToken) {
3648          cleanup();
3649          resolve();
3650          return;
3651        }
3652        themeLink.href = href;
3653        themeLink.setAttribute('data-theme-href', href);
3654        cleanup();
3655        resolve();
3656      }
3657
3658      preloader.addEventListener('load', commit, { once: true });
3659      preloader.addEventListener('error', commit, { once: true });
3660      (themeLink.parentNode || document.head || document.documentElement).appendChild(preloader);
3661      setTimeout(commit, 1500);
3662    });
3663  }
3664
3665  function isThemeHrefAlreadyActive(themeLink, href) {
3666    if (!themeLink || !href) {
3667      return true;
3668    }
3669    var absoluteHref = href;
3670    try {
3671      absoluteHref = new URL(href, window.location.href).href;
3672    } catch (_err) {
3673      absoluteHref = href;
3674    }
3675    var currentHref = String(themeLink.href || '');
3676    var currentRequested = String(themeLink.getAttribute('data-theme-href') || '');
3677    return currentHref === absoluteHref || currentRequested === href || currentRequested === absoluteHref;
3678  }
3679
3680  function updateThemeStylesheet(theme) {
3681    var nextTheme = normalizeThemeName(theme);
3682    var themeVersion = String(window.__wizardryThemeStylesheetVersion || '20260622-lapidarist-restore1');
3683    var href = '/static/themes/' + encodeURIComponent(nextTheme) + '.css?v=' + encodeURIComponent(themeVersion);
3684    var themeLink = document.getElementById('theme-stylesheet');
3685    if (isThemeHrefAlreadyActive(themeLink, href)) {
3686      return Promise.resolve();
3687    }
3688    pulseThemeSwitchVisualState();
3689    return swapThemeStylesheet(href);
3690  }
3691
3692  function loadTheme() {
3693    var bootstrapConfig = readBootstrapConfig();
3694    if (bootstrapConfig) {
3695      applySiteConfig(bootstrapConfig);
3696      return updateThemeStylesheet(state.currentTheme)
3697        .then(function () {
3698          updateThemeSelect();
3699        })
3700        .catch(function () {
3701          updateThemeSelect();
3702        });
3703    }
3704
3705    return fetch('/cgi/blog-get-config', { cache: 'no-store' })
3706      .then(function (res) { return res.json(); })
3707      .then(function (data) {
3708        applySiteConfig(data);
3709        if (data && data.theme) {
3710          return updateThemeStylesheet(state.currentTheme);
3711        }
3712      })
3713      .then(function () {
3714        updateThemeSelect();
3715      })
3716      .catch(function () {
3717        var cached = readCachedPlugins();
3718        if (cached) {
3719          publishPlugins(cached);
3720          syncPluginAuthUi();
3721        }
3722        updateThemeSelect();
3723      });
3724  }
3725
3726  function saveTheme(theme) {
3727    var params = new URLSearchParams({ theme: theme });
3728    fetch('/cgi/blog-set-theme', {
3729      method: 'POST',
3730      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
3731      body: params.toString()
3732    }).catch(function () {
3733      // Keep local style even if persistence fails.
3734    });
3735  }
3736
3737  function bindThemeSelect() {
3738    var themeSelect = document.getElementById('theme-select');
3739    if (!themeSelect) {
3740      return;
3741    }
3742
3743    function preserveFocus() {
3744      if (document.activeElement === themeSelect) {
3745        return;
3746      }
3747      setTimeout(function () {
3748        try {
3749          themeSelect.focus({ preventScroll: true });
3750        } catch (_) {
3751          themeSelect.focus();
3752        }
3753      }, 0);
3754    }
3755
3756    function applySelectedTheme(nextTheme) {
3757      state.currentTheme = normalizeThemeName(nextTheme);
3758      cacheTheme(state.currentTheme);
3759      updateThemeStylesheet(state.currentTheme);
3760      saveTheme(state.currentTheme);
3761      preserveFocus();
3762    }
3763
3764    themeSelect.addEventListener('keydown', function (event) {
3765      if ((event.key !== 'ArrowDown' && event.key !== 'ArrowUp') || event.altKey || event.ctrlKey || event.metaKey) {
3766        return;
3767      }
3768      var enabledValues = Array.prototype.slice.call(themeSelect.options || []).filter(function (opt) {
3769        return !opt.disabled;
3770      }).map(function (opt) {
3771        return String(opt.value || '');
3772      });
3773      if (!enabledValues.length) {
3774        return;
3775      }
3776      var current = String(themeSelect.value || '');
3777      var currentIndex = enabledValues.indexOf(current);
3778      if (currentIndex < 0) {
3779        currentIndex = 0;
3780      }
3781      var nextIndex = event.key === 'ArrowDown'
3782        ? (currentIndex + 1) % enabledValues.length
3783        : (currentIndex - 1 + enabledValues.length) % enabledValues.length;
3784      var nextTheme = enabledValues[nextIndex];
3785      if (!nextTheme || nextTheme === current) {
3786        event.preventDefault();
3787        return;
3788      }
3789      event.preventDefault();
3790      event.stopPropagation();
3791      themeSelect.value = nextTheme;
3792      applySelectedTheme(nextTheme);
3793    });
3794
3795    themeSelect.addEventListener('change', function (event) {
3796      var nextTheme = event.target.value;
3797      applySelectedTheme(nextTheme);
3798    });
3799  }
3800
3801  function bindUiEvents() {
3802    if (els.loginBtn) {
3803      els.loginBtn.addEventListener('click', function () {
3804        startPrimaryLogin().catch(function (err) {
3805          showNavToast(err.message || 'Nostr signer login failed.', 'info', 4200);
3806          showAuthModal(preferredUnsignedLoginTab());
3807        });
3808      });
3809    }
3810
3811    if (els.loginMoreBtn && els.loginMenu) {
3812      els.loginMoreBtn.addEventListener('click', function (event) {
3813        event.preventDefault();
3814        event.stopPropagation();
3815        if (els.loginMenu.hidden) {
3816          openLoginMenu();
3817        } else {
3818          closeLoginMenu();
3819        }
3820      });
3821    }
3822
3823    if (els.loginMenuRegister) {
3824      els.loginMenuRegister.addEventListener('click', function () {
3825        closeLoginMenu();
3826        showAuthModal('register');
3827      });
3828    }
3829
3830    if (els.loginMenuPhone) {
3831      els.loginMenuPhone.addEventListener('click', function () {
3832        closeLoginMenu();
3833        showAuthModal('phone');
3834      });
3835    }
3836
3837    if (els.loginMenuManual) {
3838      els.loginMenuManual.addEventListener('click', function () {
3839        closeLoginMenu();
3840        showAuthModal('manual');
3841        setAuthControlsDisabled(true);
3842        prepareManualLogin().catch(function (err) {
3843          setAuthMessage(err.message || 'Failed to prepare manual login.', 'error');
3844        }).finally(function () {
3845          setAuthControlsDisabled(false);
3846        });
3847      });
3848    }
3849
3850    if (els.loginMenuLearn) {
3851      els.loginMenuLearn.addEventListener('click', function () {
3852        closeLoginMenu();
3853        showAuthModal('register');
3854      });
3855    }
3856
3857    if (els.authModal) {
3858      els.authModal.addEventListener('click', function (event) {
3859        if (event.target && event.target.hasAttribute('data-close-auth-modal')) {
3860          hideAuthModal();
3861        }
3862      });
3863    }
3864    if (els.menuBtn && els.menuPanel) {
3865      els.menuBtn.addEventListener('click', function (event) {
3866        event.preventDefault();
3867        event.stopPropagation();
3868        closeNavOverflowMenu();
3869        closeLoginMenu();
3870        if (els.menuPanel.hidden) {
3871          openUserMenu();
3872        } else {
3873          closeUserMenu();
3874        }
3875      });
3876    }
3877
3878    if (els.navOverflowBtn && els.navOverflowPanel) {
3879      els.navOverflowBtn.addEventListener('click', function (event) {
3880        event.preventDefault();
3881        event.stopPropagation();
3882        closeLoginMenu();
3883        closeUserMenu();
3884        if (els.navOverflowPanel.hidden) {
3885          openNavOverflowMenu();
3886        } else {
3887          closeNavOverflowMenu();
3888        }
3889      });
3890    }
3891
3892    if (els.menuLogoutBtn) {
3893      els.menuLogoutBtn.addEventListener('click', function () {
3894        closeUserMenu();
3895        logout();
3896      });
3897    }
3898
3899    if (els.menuLogoutMoreBtn && els.menuLogoutSubmenu) {
3900      els.menuLogoutMoreBtn.addEventListener('click', function (event) {
3901        event.preventDefault();
3902        event.stopPropagation();
3903        if (els.menuLogoutSubmenu.hidden) {
3904          openLogoutSubmenu();
3905        } else {
3906          closeLogoutSubmenu();
3907        }
3908      });
3909    }
3910
3911    if (els.menuLogoutEverywhereBtn) {
3912      els.menuLogoutEverywhereBtn.addEventListener('click', function () {
3913        closeUserMenu();
3914        setAuthMessage('Preparing log out other sessions challenge...', 'warn');
3915        logoutEverywhere().catch(function (err) {
3916          setAuthMessage(err.message || 'Log out other sessions failed.', 'error');
3917          showAuthModal('register');
3918        });
3919      });
3920    }
3921
3922    document.addEventListener('click', function (event) {
3923      if (!els.userMenu || els.userMenu.style.display === 'none') {
3924        if (els.loginSplit && els.loginMenu && !els.loginMenu.hidden && !els.loginSplit.contains(event.target)) {
3925          closeLoginMenu();
3926        }
3927        if (els.navOverflowMenu && els.navOverflowPanel && !els.navOverflowPanel.hidden && !els.navOverflowMenu.contains(event.target)) {
3928          closeNavOverflowMenu();
3929        }
3930        return;
3931      }
3932      if (!els.userMenu.contains(event.target)) {
3933        closeUserMenu();
3934      } else if (els.menuLogoutSubmenu && !els.menuLogoutSubmenu.hidden && !els.menuLogoutSubmenu.contains(event.target) && event.target !== els.menuLogoutMoreBtn && !(els.menuLogoutMoreBtn && els.menuLogoutMoreBtn.contains(event.target))) {
3935        closeLogoutSubmenu();
3936      }
3937      if (els.loginSplit && els.loginMenu && !els.loginMenu.hidden && !els.loginSplit.contains(event.target)) {
3938        closeLoginMenu();
3939      }
3940      if (els.navOverflowMenu && els.navOverflowPanel && !els.navOverflowPanel.hidden && !els.navOverflowMenu.contains(event.target)) {
3941        closeNavOverflowMenu();
3942      }
3943    });
3944
3945    document.addEventListener('keydown', function (event) {
3946      if (event.key === 'Escape' && els.authModal && !els.authModal.hidden) {
3947        hideAuthModal();
3948        return;
3949      }
3950      if (event.key === 'Escape' && els.loginMenu && !els.loginMenu.hidden) {
3951        closeLoginMenu();
3952      }
3953      if (event.key === 'Escape' && els.menuPanel && !els.menuPanel.hidden) {
3954        if (els.menuLogoutSubmenu && !els.menuLogoutSubmenu.hidden) {
3955          closeLogoutSubmenu();
3956          return;
3957        }
3958        closeUserMenu();
3959      }
3960      if (event.key === 'Escape' && els.navOverflowPanel && !els.navOverflowPanel.hidden) {
3961        closeNavOverflowMenu();
3962      }
3963    });
3964
3965    if (els.authRegisterBtn) {
3966      els.authRegisterBtn.addEventListener('click', function () {
3967        startDesktopSignerLogin(true, '').catch(function (err) {
3968          setAuthMessage(err.message || 'Nostr signer login failed.', 'error');
3969        });
3970      });
3971    }
3972
3973    if (els.authModal) {
3974      els.authModal.addEventListener('click', function (event) {
3975        var trigger = event.target && event.target.closest ? event.target.closest('[data-auth-route]') : null;
3976        if (!trigger || !els.authModal.contains(trigger)) {
3977          return;
3978        }
3979        event.preventDefault();
3980        setActiveAuthTab(trigger.getAttribute('data-auth-route'), trigger.getAttribute('data-auth-flavor'));
3981      });
3982    }
3983
3984    function copyNip46Uri() {
3985      return initNip46Pairing().then(function () {
3986        return refreshUnpairedNip46Link();
3987      }).then(function () {
3988        var uri = currentNip46Uri();
3989        if (!uri) {
3990          throw new Error('Nostr Connect link is not ready yet.');
3991        }
3992        if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
3993          return navigator.clipboard.writeText(uri);
3994        }
3995        throw new Error('Clipboard access is unavailable. Select and copy the link text below.');
3996      }).then(function () {
3997        setAuthMessage('Nostr Connect link copied.', 'ok');
3998      }).catch(function (err) {
3999        setAuthMessage(err.message || 'Could not copy Nostr Connect link.', 'error');
4000      });
4001    }
4002
4003    if (els.authNip46UriCopy) {
4004      els.authNip46UriCopy.addEventListener('click', copyNip46Uri);
4005    }
4006
4007    if (els.authNip46Open) {
4008      els.authNip46Open.addEventListener('click', function (event) {
4009        event.preventDefault();
4010        if (!state.nip46.signerPubkey && !hasNostrTools()) {
4011          setAuthMessage('Phone signer setup is still loading. Tap Connect Nostr again in a moment.', 'w
4011arn');
4012          initNip46Pairing().catch(function (err) {
4013            setAuthMessage(err.message || 'Could not prepare a fresh Nostr Connect link.', 'error');
4014          });
4015          return;
4016        }
4017        var uri = String(els.authNip46Open.getAttribute('data-nip46-uri') || els.authNip46Open.getAttribute('href') || '');
4018        if (uri.indexOf('nostrconnect://') === 0) {
4019          openNativeDeepLink(uri, 'Nostr Connect link is not ready yet. The QR setup is still loading.');
4020          return;
4021        }
4022        rotateUnpairedNip46StateNow();
4023        refreshUnpairedNip46Link().then(function () {
4024          var freshUri = currentNip46Uri();
4025          openNativeDeepLink(freshUri, 'Nostr Connect link is not ready yet. The QR setup is still loading.');
4026        }).catch(function (err) {
4027          setAuthMessage(err && err.message ? err.message : 'Could not prepare a Nostr Connect link.', 'error');
4028        });
4029      });
4030    }
4031
4032    if (els.authNip46Reset) {
4033      els.authNip46Reset.addEventListener('click', function () {
4034        setAuthMessage('Making a fresh phone signer link...', 'warn');
4035        resetPhonePairingLink().then(function () {
4036          updatePhoneContinueState();
4037          setAuthMessage('Fresh phone signer link is ready. Open the app link or scan the QR.', 'ok');
4038        }).catch(function (err) {
4039          setAuthMessage(err.message || 'Could not make a fresh phone signer link.', 'error');
4040        });
4041      });
4042    }
4043
4044    if (els.authPhoneBtn) {
4045      els.authPhoneBtn.addEventListener('click', function () {
4046        continuePhoneSignerLogin(false);
4047      });
4048    }
4049
4050    if (els.authManualStart) {
4051      els.authManualStart.addEventListener('click', function () {
4052        setAuthControlsDisabled(true);
4053        prepareManualLogin().catch(function (err) {
4054          setAuthMessage(err.message || 'Failed to create manual challenge.', 'error');
4055        }).finally(function () {
4056          setAuthControlsDisabled(false);
4057        });
4058      });
4059    }
4060
4061    if (els.authManualSubmit) {
4062      els.authManualSubmit.addEventListener('click', function () {
4063        setAuthMessage('Verifying pasted signed login...', 'warn');
4064        setAuthControlsDisabled(true);
4065        Promise.resolve()
4066          .then(function () {
4067            return submitManualLogin();
4068          })
4069          .catch(function (err) {
4070            setAuthMessage(err.message || 'Manual login failed.', 'error');
4071          })
4072          .finally(function () {
4073            setAuthControlsDisabled(false);
4074          });
4075      });
4076    }
4077
4078    document.addEventListener('visibilitychange', function () {
4079      if (!document.hidden) {
4080        refreshPhoneSignerListenerAfterReturn('visibility');
4081      }
4082    });
4083
4084    window.addEventListener('pageshow', function () {
4085      refreshPhoneSignerListenerAfterReturn('pageshow');
4086    });
4087
4088    window.addEventListener('focus', function () {
4089      refreshPhoneSignerListenerAfterReturn('focus');
4090    });
4091
4092    window.addEventListener('resize', function () {
4093      if (els.authModal && !els.authModal.hidden) {
4094        syncAuthModalNavOffset();
4095      }
4096    });
4097
4098    if (window.visualViewport) {
4099      window.visualViewport.addEventListener('resize', function () {
4100        if (els.authModal && !els.authModal.hidden) {
4101          syncAuthModalNavOffset();
4102        }
4103      });
4104    }
4105  }
4106
4107  function bootstrap() {
4108    var optimisticIsLoggedIn = false;
4109    var optimisticIsAdmin = false;
4110    var optimisticName = '';
4111    var optimisticSiteTitle = '';
4112    try {
4113      optimisticIsLoggedIn = hasStoredSessionToken();
4114      optimisticIsAdmin = String(localStorage.getItem('last_auth_is_admin') || '') 
4114=== '1';
4115      optimisticName = String(localStorage.getItem('last_auth_player_name') || localStorage.getItem('last_auth_username') || '').trim();
4116      optimisticSiteTitle = String(localStorage.getItem(SITE_TITLE_CACHE_KEY) || '').trim();
4117    } catch (_err) {
4118      optimisticIsLoggedIn = false;
4119      optimisticIsAdmin = false;
4120      optimisticName = '';
4121      optimisticSiteTitle = '';
4122    }
4123    updateNavSiteSignature(optimisticSiteTitle);
4124    var cachedPlugins = readCachedPlugins();
4125    if (cachedPlugins) {
4126      publishPlugins(cachedPlugins);
4127    } else {
4128      window.__wizardryPlugins = state.plugins;
4129      window.__wizardryVideoChatEnabled = !!state.plugins.video_chat;
4130    }
4131    state.isAuthenticated = optimisticIsLoggedIn;
4132    applyLoggedInUi(optimisticIsLoggedIn, optimisticIsAdmin, optimisticName);
4133    placeNavOverflowMenuWithPageLinks();
4134
4135    renderComposeIcon(readComposeIconIndex());
4136    highlightCurrentPage();
4137    applyInitialHighlightInSyncWithContent();
4138    if (!optimisticIsLoggedIn) {
4139      warmNavbarNostrPagePrefetch();
4140    }
4141    loadNavbarNostrPages()
4142      .then(function () {
4143        highlightCurrentPage();
4144      })
4145      .catch(function () {
4146        // Keep startup resilient; cached nav remains usable.
4147      });
4148    checkAuth();
4149    loadTheme();
4150    window.addEventListener('hashchange', highlightCurrentPage);
4151    markNavOverflowReady();
4152    bindThemeSelect();
4153    bindUiEvents();
4154    bindNavbarNostrPagePrefetch();
4155    window.blogAuth = window.blogAuth || {};
4156    window.blogAuth.openLoginModal = showAuthModal;
4157    window.blogAuth.startLogin = function (options) {
4158      var showFallbackModal = !(options && options.fallbackModal === false);
4159      return startPrimaryLogin(options).catch(function (err) {
4160        showNavToast(err && err.message ? err.message : 'Nostr signer login failed.', 'info', 4200);
4161        if (showFallbackModal) {
4162          showAuthModal(preferredUnsignedLoginTab());
4163        }
4164        throw err;
4165      });
4166    };
4167    window.blogAuth.showToast = showNavToast;
4168    flushRememberedNavToast();
4169  }
4170
4171  document.addEventListener('DOMContentLoaded', bootstrap);
4172})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.