PageSourceSearch

https://jayreddinjr-jr-odysseus-hf.hf.space/static/js/ui.js

js jayreddinjr-jr-odysseus-hf.hf.space collected 2026-10-04 16:59:00 UTC 50,225 bytes, 1,288 lines download raw bytes

1// static/js/ui.js
2
3/**
4 * UI utilities for toasts, modals, scrolling, and user feedback
5 */
6
7import themeModule from './theme.js';
8import * as Modals from './modalManager.js';
9import spinnerModule from './spinner.js';
10import { registerMenuDismiss, dismissTopMenu, dismissOrRemove } from './escMenuStack.js';
11
12let toastEl = null;
13let autoScrollEnabled = true;
14let hoveredToggleCard = null;
15let hoveredToggleWindow = null;
16let hoveredDockChip = null;
17let _lastPointerClientX = null;
18let _lastPointerClientY = null;
19
20// Smooth scroll state
21let _scrollRafId = null;
22let _scrollBox = null;
23
24function _isTextEditingTarget(target) {
25  const el = target && target.nodeType === 1 ? target : target?.parentElement;
26  return !!(el && el.closest('input, textarea, select, [contenteditable="true"], [contenteditable=""]'));
27}
28
29function _targetEl(target) {
30  return target && target.nodeType === 1 ? target : target?.parentElement || null;
31}
32
33const SPACE_CARD_SELECTOR = [
34  '#email-lib-modal .doclib-card',
35  '#doclib-modal .doclib-card',
36  '#doclib-modal .doclib-chat-row',
37  '#memory-modal .doclib-card',
38  '#tasks-modal .task-card',
39  '#tasks-modal .task-log-row',
40  '#research-overlay [data-job-id]',
41  '#cookbook-modal .doclib-card',
42  '.email-reader-tab-modal .doclib-card',
43  '.email-window-modal .doclib-card',
44].join(', ');
45
46const SPACE_BLOCKED_SELECTOR = [
47  'button',
48  'a',
49  'input',
50  'textarea',
51  'select',
52  '[contenteditable="true"]',
53  '[contenteditable=""]',
54  '.recipient-chip',
55  '.doclib-card-dropdown',
56  '.email-card-dropdown',
57  '.task-log-row-actions',
58  '.modal-header',
59].join(', ');
60
61function _visibleModalForSpace(win) {
62  const modal = win?.closest?.('.modal[id]');
63  if (!modal || modal.classList.contains('hidden') || modal.classList.contains('modal-minimized')) return null;
64  return modal;
65}
66
67function _isSpaceVisible(el) {
68  if (!el || !document.contains(el)) return false;
69  if (el.closest?.('.modal.hidden, .modal.modal-minimized, [hidden]')) return false;
70  return true;
71}
72
73function _spaceWindowId(win) {
74  if (!win || !document.contains(win)) return null;
75  const modal = _visibleModalForSpace(win);
76  if (modal && Modals.isRegistered(modal.id)) return modal.id;
77  if (win.closest?.('.doc-editor-pane') && Modals.isRegistered('doc-panel') && !Modals.isMinimized('doc-panel')) return 'doc-panel';
78  return null;
79}
80
81function _windowAtPointer() {
82  if (_lastPointerClientX == null || _lastPointerClientY == null) return null;
83  const x = _lastPointerClientX;
84  const y = _lastPointerClientY;
85  const candidates = [
86    ...document.querySelectorAll('.modal:not(.hidden):not(.modal-minimized) .modal-content'),
87    ...document.querySelectorAll('.doc-editor-pane'),
88  ].filter(el => {
89    if (!document.contains(el)) return false;
90    const r = el.getBoundingClientRect();
91    return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
92  });
93  if (!candidates.length) return null;
94  return candidates.reduce((top, el) => {
95    const mz = parseInt(getComputedStyle(el.closest('.modal') || el).zIndex, 10) || 0;
96    const tz = parseInt(getComputedStyle(top.closest('.modal') || top).zIndex, 10) || 0;
97    return mz >= tz ? el : top;
98  });
99}
100
101function _containsPointer(el) {
102  if (!el || _lastPointerClientX == null || _lastPointerClientY == null) return false;
103  const r = el.getBoundingClientRect();
104  return _lastPointerClientX >= r.left && _lastPointerClientX <= r.right
105    && _lastPointerClientY >= r.top && _lastPointerClientY <= r.bottom;
106}
107
108function _closeHoveredWindow() {
109  let win = _windowAtPointer();
110  if (!win) {
111    try {
112      const underPointer = document.elementFromPoint(_lastPointerClientX, _lastPointerClientY);
113      win = underPointer?.closest?.('.modal:not(.hidden):not(.modal-minimized) .modal-content, .doc-editor-pane') || null;
114    } catch {}
115  }
116  if (!win) win = hoveredToggleWindow;
117  if (!win || !document.contains(win)) return false;
118  const modalForWin = win.closest?.('.modal[id]');
119  if (modalForWin?.id === 'email-lib-modal') {
120    const closeBtn = document.getElementById('email-lib-close') || modalForWin.querySelector('.close-btn');
121    if (closeBtn) {
122      try { closeBtn.click(); return true; } catch {}
123    }
124    try { modalForWin.remove(); return true; } catch {}
125  }
126  const id = _spaceWindowId(win);
127  if (id && Modals.isRegistered(id)) {
128    Modals.close(id);
129    return true;
130  }
131  const modal = _visibleModalForSpace(win);
132  if (!modal) return false;
133  const closeBtn = modal.querySelector('.close-btn, .modal-close, .modal-close-btn, [data-action="close"]');
134  if (closeBtn) {
135    try { closeBtn.click(); return true; } catch {}
136  }
137  try { modal.classList.add('hidden'); return true; } catch {}
138  return false;
139}
140
141function _spaceIsBlocked(e, surface) {
142  const target = _targetEl(e.target);
143  if (!target) return false;
144  if (_isTextEditingTarget(target)) return !surface || surface.contains(target);
145  const blocked = target.closest?.(SPACE_BLOCKED_SELECTOR);
146  return !!(blocked && (!surface || surface.contains(blocked)));
147}
148
149function _activateSpaceCard(card) {
150  if (!card || !document.contains(card)) return false;
151  if (card.matches('#tasks-modal .task-card')) {
152    const titleRow = card.querySelector('.memory-item-title')?.closest('div');
153    if (titleRow) {
154      titleRow.click();
155      return true;
156    }
157  }
158  card.dataset.spaceToggle = '1';
159  card.click();
160  setTimeout(() => {
161    try { delete card.dataset.spaceToggle; } catch {}
162  }, 0);
163  return true;
164}
165
166function _initHoverCardSpaceToggle() {
167  if (document._odysseusHoverCardSpaceToggle) return;
168  document._odysseusHoverCardSpaceToggle = true;
169  document.addEventListener('pointerover', (e) => {
170    _lastPointerClientX = e.clientX;
171    _lastPointerClientY = e.clientY;
172    const chip = e.target?.closest?.('.minimized-dock-chip[data-modal-id]');
173    if (chip) hoveredDockChip = chip;
174    const card = e.target?.closest?.(SPACE_CARD_SELECTOR);
175    if (card) hoveredToggleCard = card;
176    const win = e.target?.closest?.('.modal:not(.hidden):not(.modal-minimized) .modal-content, .doc-editor-pane');
177    if (win) hoveredToggleWindow = win;
178  }, true);
179  document.addEventListener('pointermove', (e) => {
180    _lastPointerClientX = e.clientX;
181    _lastPointerClientY = e.clientY;
182  }, true);
183  document.addEventListener('pointerout', (e) => {
184    const next = e.relatedTarget;
185    if (hoveredDockChip && (!next || !hoveredDockChip.contains(next))) hoveredDockChip = null;
186    if (hoveredToggleCard && (!next || !hoveredToggleCard.contains(next))) hoveredToggleCard = null;
187    if (hoveredToggleWindow && (!next || !hoveredToggleWindow.contains(next))) hoveredToggleWindow = null;
188  }, true);
189  document.addEventListener('keydown', (e) => {
190    if (e.code !== 'Space' || e.repeat) return;
191    if (hoveredToggleCard && _isSpaceVisible(hoveredToggleCard)) {
192      if (_spaceIsBlocked(e, hoveredToggleCard)) return;
193      e.preventDefault();
194      _activateSpaceCard(hoveredToggleCard);
195      return;
196    }
197    if (hoveredDockChip && document.contains(hoveredDockChip)) {
198      if (_spaceIsBlocked(e, hoveredDockChip)) return;
199      const id = hoveredDockChip.dataset.modalId;
200      if (id && Modals.isRegistered(id)) {
201        e.preventDefault();
202        Modals.restore(id);
203      }
204      return;
205    }
206    const id = _spaceWindowId(hoveredToggleWindow);
207    if (!id) return;
208    if (_spaceIsBlocked(e, hoveredToggleWindow)) return;
209    e.preventDefault();
210    Modals.minimize(id);
211  }, true);
212}
213
214_initHoverCardSpaceToggle();
215
216/**
217 * Copy text to clipboard
218 */
219export async function copyToClipboard(text) {
220  try {
221    await navigator.clipboard.writeText(text);
222    showToast('Copied');
223  }
224  catch {
225    const ta = document.createElement('textarea');
226    ta.value = text;
227    ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px;opacity:0';
228    document.body.appendChild(ta);
229    ta.select();
230    document.execCommand('copy');
231    document.body.removeChild(ta);
232    showToast('Copied');
233  }
234}
235
236// Wire swipe-to-dismiss on the shared toast element. Runs once, the first
237// time a toast is shown. Tracks horizontal touch drag; if the user drags
238// more than DISMISS_PX, the toast slides off in the drag direction and
239// hides early. Anything less snaps back. Desktop unaffected (touch
240// listeners only fire from a touchscreen — mouse is handled by the
241// existing × button and auto-hide timer).
242function _wireToastSwipe(el) {
243  if (!el || el._swipeWired) return;
244  el._swipeWired = true;
245  const DISMISS_PX = 70;
246  let startX = 0, currentX = 0, swiping = false;
247  el.addEventListener('touchstart', (e) => {
248    if (!el.classList.contains('show')) return;
249    const t = e.touches[0];
250    if (!t) return;
251    startX = t.clientX;
252    currentX = t.clientX;
253    swiping = true;
254    // Kill the slide-in transition mid-flight so the touch tracks the
255    // finger 1:1 instead of fighting a still-running animation.
256    el.style.transition = 'none';
257  }, { passive: true });
258  el.addEventListener('touchmove', (e) => {
259    if (!swiping) return;
260    const t = e.touches[0];
261    if (!t) return;
262    currentX = t.clientX;
263    const dx = currentX - startX;
264    el.style.transform = `translateX(${dx}px)`;
265    // Fade as the toast leaves the rest position — visual cue for
266    // approaching the dismiss threshold.
267    el.style.opacity = String(Math.max(0.2, 1 - Math.abs(dx) / 200));
268  }, { passive: true });
269  const endSwipe = () => {
270    if (!swiping) return;
271    swiping = false;
272    const dx = currentX - startX;
273    // Restore the transition so the next mutation animates.
274    el.style.transition = '';
275    if (Math.abs(dx) > DISMISS_PX) {
276      // Fling off in the drag direction, then hide.
277      el.style.transform = `translateX(${dx > 0 ? '120%' : '-120%'})`;
278      el.style.opacity = '0';
279      clearTimeout(el._hideTimer);
280      setTimeout(() => {
281        el.classList.remove('show');
282        el.classList.add('exiting');
283        el.style.transform = '';
284        el.style.opacity = '';
285      }, 180);
286    } else {
287      // Snap back to rest.
288      el.style.transform = '';
289      el.style.opacity = '';
290    }
291  };
292  el.addEventListener('touchend', endSwipe);
293  el.addEventListener('touchcancel', endSwipe);
294}
295
296/**
297 * Show success toast message
298 */
299export function showToast(msg, durationOrOpts) {
300  if (!toastEl) {
301    toastEl = document.getElementById('toast');
302  }
303  _wireToastSwipe(toastEl);
304  toastEl.textContent = '';
305  toastEl.classList.remove('error');
306
307  let duration = 1200, actionLabel = null, onAction = null, actionHint = null, actionIcon = null, leadingIcon = null;
308  if (typeof durationOrOpts === 'object' && durationOrOpts) {
309    duration = durationOrOpts.duration || 5000;
310    actionLabel = durationOrOpts.action;
311    onAction = durationOrOpts.onAction;
312    actionHint = durationOrOpts.actionHint || null;
313    actionIcon = durationOrOpts.actionIcon || null;
314    leadingIcon = durationOrOpts.leadingIcon || null;
315  } else if (typeof durationOrOpts === 'number') {
316    duration = durationOrOpts;
317  }
318
319  const textSpan = document.createElement('span');
320  if (leadingIcon === 'check') {
321    const icon = document.createElement('span');
322    icon.className = 'toast-checkmark';
323    icon.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>';
324    toastEl.appendChild(icon);
325  } else if (leadingIcon === 'spinner') {
326    const wp = spinnerModule.createWhirlpool(14);
327    const icon = wp.element;
328    icon.classList.add('toast-whirlpool');
329    icon.style.cssText = 'width:14px;height:14px;margin:0 8px 0 0;display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;';
330    toastEl.appendChild(icon);
331  }
332  textSpan.textContent = msg;
333  toastEl.appendChild(textSpan);
334
335  if (actionLabel && onAction) {
336    // Wrap the action in a small column so we can stack a Ctrl-Z-style hint
337    // directly under the button.
338    const stack = document.createElement('span');
339    stack.style.cssText = 'display:inline-flex;flex-direction:column;align-items:center;gap:1px;margin-left:10px;line-height:1;';
340
341    const btn = document.createElement('button');
342    // If the caller supplied an SVG icon, prepend it. We trust the icon string
343    // (only set internally) — never accept caller-controlled HTML otherwise.
344    if (actionIcon) {
345      btn.innerHTML = `<span style="display:inline-flex;align-items:center;gap:5px;">${actionIcon}<span></span></span>`;
346      btn.querySelector('span span').textContent = actionLabel;
347    } else {
348      btn.textContent = actionLabel;
349    }
350    // The toast itself is `pointer-events: none` so it doesn't block clicks
351    // beneath it. With an action button we need to flip both the toast AND
352    // the button so the user can actually click Undo. The flag is reset on
353    // the next plain showToast / showError call (those overwrite textContent
354    // which strips the button + we clear inline style at the top below).
355    btn.style.cssText = 'padding:2px 10px;border:1px solid var(--fg);border-radius:4px;background:none;color:var(--fg);cursor:pointer;font-size:12px;pointer-events:auto;display:inline-flex;align-items:center;';
356    btn.addEventListener('click', (e) => {
357      e.stopPropagation();
358      e.preventDefault();
359      toastEl.classList.remove('show');
360      onAction();
361    });
362    stack.appendChild(btn);
363
364    // Keyboard-shortcut hints (Ctrl+Z / ⌘Z) are meaningless on touch devices —
365    // skip them on mobile so the toast just shows the Undo button.
366    if (actionHint && window.innerWidth > 768) {
367      const hint = document.createElement('span');
368      hint.textContent = actionHint;
369      hint.style.cssText = 'font-size:9px;opacity:0.55;letter-spacing:0.4px;text-transform:uppercase;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;margin-top:1px;pointer-events:none;';
370      stack.appendChild(hint);
371    }
372
373    toastEl.appendChild(stack);
374
375    // Small × to dismiss the toast without taking the action. Useful when
376    // the user already acted (or just doesn't want the banner sitting there).
377    const closeBtn = document.createElement('button');
378    closeBtn.type = 'button';
379    closeBtn.setAttribute('aria-label', 'Dismiss');
380    closeBtn.title = 'Dismiss';
381    closeBtn.textContent = '×';
382    closeBtn.style.cssText = 'margin-left:8px;padding:0;width:20px;height:20px;line-height:1;border:none;background:none;color:var(--fg);opacity:0.55;cursor:pointer;font-size:18px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;pointer-events:auto;';
383    closeBtn.addEventListener('mouseenter', () => { closeBtn.style.opacity = '1'; });
384    closeBtn.addEventListener('mouseleave', () => { closeBtn.style.opacity = '0.55'; });
385    closeBtn.addEventListener('click', (e) => {
386      e.stopPropagation();
387      e.preventDefault();
388      clearTimeout(toastEl._hideTimer);
389      toastEl.classList.add('exiting');
390      toastEl.classList.remove('show');
391    });
392    toastEl.appendChild(closeBtn);
393
394    toastEl.style.pointerEvents = 'auto';
395  } else {
396    // No action — restore the default non-blocking behavior.
397    toastEl.style.pointerEvents = '';
398  }
399
400  // Pin to top-right via CSS — clear any legacy inline overrides so the
401  // slide-in-from-right / slide-out-to-left transition can run cleanly.
402  toastEl.style.left = '';
403  toastEl.style.transform = '';
404  toastEl.classList.remove('exiting');
405  toastEl.classList.add('show');
406  clearTimeout(toastEl._hideTimer);
407  toastEl._hideTimer = setTimeout(() => {
408    // Add `exiting` so the CSS rule slides it off to the LEFT instead of
409    // back to the right (where it came from). We piggyback on the same
410    // .toast base; .exiting overrides the resting transform.
411    toastEl.classList.add('exiting');
412    toastEl.classList.remove('show');
413    // Reset pointer-events so an action-toast (which sets it to 'auto'
414    // for its clickable button) doesn't leave the toast intercepting
415    // clicks after it's slid away. Was previously only cleared on the
416    // NEXT plain toast, so a lingering action-toast could appear to
417    // "lock" interaction near the top-right.
418    toastEl.style.pointerEvents = '';
419  }, duration);
420}
421
422/**
423 * Show error toast message
424 */
425export function showError(msg) {
426  if (!toastEl) {
427    toastEl = document.getElementById('toast');
428  }
429  _wireToastSwipe(toastEl);
430  toastEl.textContent = msg;
431  toastEl.classList.add('error');
432  toastEl.style.left = '';
433  toastEl.style.transform = '';
434  toastEl.classList.remove('exiting');
435  toastEl.classList.add('show');
436  clearTimeout(toastEl._hideTimer);
437  toastEl._hideTimer = setTimeout(() => {
438    toastEl.classList.add('exiting');
439    toastEl.classList.remove('show');
440  }, 3000);
441}
442
443/**
444 * Smooth-scroll chat history to bottom using rAF lerp.
445 * Throttled during streaming so it doesn't fight user scrolling.
446 */
447let _scrollThrottleTimer = null;
448export function scrollHistory() {
449  if (!autoScrollEnabled) return;
450  if (!_scrollBox) {
451    _scrollBox = document.getElementById('chat-history');
452  }
453  // Throttle: only start a new scroll animation every 500ms
454  if (_scrollThrottleTimer) return;
455  _scrollThrottleTimer = setTimeout(() => { _scrollThrottleTimer = null; }, 500);
456  if (!_scrollRafId) {
457    _scrollRafId = requestAnimationFrame(_smoothScrollStep);
458  }
459}
460
461function _smoothScrollStep() {
462  const box = _scrollBox;
463  if (!box || !autoScrollEnabled) {
464    _scrollRafId = null;
465    return;
466  }
467  const target = box.scrollHeight - box.clientHeight;
468  const current = box.scrollTop;
469  const diff = target - current;
470
471  // If user scrolled up significantly, don't force them down
472  if (diff > 300) {
473    _scrollRafId = null;
474    return;
475  }
476
477  if (diff <= 1) {
478    box.scrollTop = target;
479    _scrollRafId = null;
480    return;
481  }
482
483  // Lerp: gentle catch-up
484  const factor = window.innerWidth <= 768 ? 0.4 : 0.2;
485  box.scrollTop = current + diff * factor;
486  _scrollRafId = requestAnimationFrame(_smoothScrollStep);
487}
488
489/**
490 * Instant scroll to bottom — use for non-streaming contexts
491 * like loading history or switching sessions.
492 */
493export function scrollHistoryInstant() {
494  if (!_scrollBox) {
495    _scrollBox = document.getElementById('chat-history');
496  }
497  if (_scrollBox) {
498    _scrollBox.scrollTop = _scrollBox.scrollHeight;
499  }
500}
501
502/**
503 * Enable/disable auto-scroll
504 */
505export function setAutoScroll(enabled) {
506  autoScrollEnabled = enabled;
507}
508
509/**
510 * Get auto-scroll state
511 */
512export function getAutoScroll() {
513  return autoScrollEnabled;
514}
515
516/**
517 * Auto-resize textarea based on content
518 */
519export function autoResize(textarea) {
520  const lineHeight = parseInt(getComputedStyle(textarea).lineHeight);
521  const isMobile = window.innerWidth <= 768;
522  const maxHeight = isMobile ? 150 : lineHeight * 8;
523
524  // Use a hidden clone to measure without disrupting the real textarea
525  let clone = textarea._resizeClone;
526  if (!clone) {
527    clone = textarea.cloneNode(false);
528    clone.style.cssText = getComputedStyle(textarea).cssText;
529    clone.style.position = 'absolute';
530    clone.style.visibility = 'hidden';
531    clone.style.height = '0';
532    clone.style.transition = 'none';
533    clone.style.overflow = 'hidden';
534    clone.style.pointerEvents = 'none';
535    clone.style.zIndex = '-1';
536    textarea.parentNode.appendChild(clone);
537    textarea._resizeClone = clone;
538  }
539  clone.style.width = textarea.offsetWidth + 'px';
540  clone.value = textarea.value;
541  clone.style.height = '0';
542  const newHeight = Math.min(Math.max(clone.scrollHeight, lineHeight), maxHeight);
543  textarea.style.height = newHeight + 'px';
544  textarea.style.overflow = newHeight >= maxHeight ? 'auto' : 'hidden';
545}
546
547/**
548 * Debounce function for performance
549 */
550export function debounce(func, wait) {
551  let timeout;
552  return function(...args) {
553    const later = () => {
554      timeout = null;
555      func.apply(this, args);
556    };
557    clearTimeout(timeout);
558    timeout = setTimeout(later, wait);
559  };
560}
561
562/**
563 * Get element by ID (utility helper)
564 */
565export function el(id) {
566  return document.getElementById(id);
567}
568
569/**
570 * Styled confirm dialog — replaces native browser confirm().
571 * Returns a Promise<boolean>.
572 */
573export function styledConfirm(message, { confirmText = 'Confirm', cancelText = 'Cancel', danger = false } = {}) {
574  return new Promise(resolve => {
575    // Reuse or create the modal
576    let overlay = document.getElementById('styled-confirm-overlay');
577    if (!overlay) {
578      overlay = document.createElement('div');
579      overlay.id = 'styled-confirm-overlay';
580      overlay.className = 'modal';
581      overlay.innerHTML =
582        '<div class="modal-content styled-confirm-box" role="dialog" aria-modal="true" aria-labelledby="styled-confirm-title" aria-describedby="styled-confirm-msg">' +
583          '<div class="modal-header"><h4 id="styled-confirm-title">Confirm</h4></div>' +
584          '<div class="modal-body"><p id="styled-confirm-msg"></p></div>' +
585          '<div class="modal-footer">' +
586            '<button id="styled-confirm-cancel"></button>' +
587            '<button id="styled-confirm-ok"></button>' +
588          '</div>' +
589        '</div>';
590      document.body.appendChild(overlay);
591    }
592
593    const msgEl = document.getElementById('styled-confirm-msg');
594    const okBtn = document.getElementById('styled-confirm-ok');
595    const cancelBtn = document.getElementById('styled-confirm-cancel');
596
597    msgEl.textContent = message;
598    okBtn.textContent = confirmText;
599    cancelBtn.textContent = cancelText;
600    okBtn.className = danger ? 'confirm-btn confirm-btn-danger' : 'confirm-btn confirm-btn-primary';
601    cancelBtn.className = 'confirm-btn confirm-btn-secondary';
602
603    // Remember what had focus so we can restore it when the dialog closes.
604    const _prevFocus = document.activeElement;
605    overlay.classList.remove('hidden');
606    overlay.style.display = '';
607
608    function cleanup(result) {
609      overlay.classList.add('hidden');
610      overlay.style.display = 'none';
611      okBtn.removeEventListener('click', onOk);
612      cancelBtn.removeEventListener('click', onCancel);
613      overlay.removeEventListener('click', onBackdrop);
614      document.removeEventListener('keydown', onKey);
615      try { _prevFocus && _prevFocus.focus && _prevFocus.focus(); } catch {}
616      resolve(result);
617    }
618    function onOk() { cleanup(true); }
619    function onCancel() { cleanup(false); }
620    function onBackdrop(e) { if (e.target === overlay) cleanup(false); }
621    function onKey(e) {
622      if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
623        e.preventDefault();
624        const active = document.activeElement;
625        if (active === okBtn) cancelBtn.focus();
626        else okBtn.focus();
627      } else if (e.key === 'Escape') {
628        e.preventDefault();
629        e.stopPropagation();
630        e.stopImmediatePropagation();
631        cleanup(false);
632      } else if (e.key === 'Tab') {
633        // Trap focus inside the dialog so Tab can't wander to the page behind.
634        e.preventDefault();
635        const f = [cancelBtn, okBtn];
636        const i = f.indexOf(document.activeElement);
637        const n = e.shiftKey ? (i <= 0 ? f.length - 1 : i - 1) : (i >= f.length - 1 ? 0 : i + 1);
638        f[n].focus();
639      }
640    }
641
642    okBtn.addEventListener('click', onOk);
643    cancelBtn.addEventListener('click', onCancel);
644    overlay.addEventListener('click', onBackdrop);
645    document.addEventListener('keydown', onKey);
646    okBtn.focus();
647  });
648}
649
650/**
651 * Styled text-input prompt — drop-in replacement for window.prompt().
652 * Resolves to the trimmed string the user typed, or null on Cancel / Escape / backdrop.
653 */
654export function styledPrompt(message, {
655  title = 'Name',
656  defaultValue = '',
657  placeholder = '',
658  confirmText = 'Save',
659  cancelText = 'Cancel',
660  maxLength = 80,
661} = {}) {
662  return new Promise(resolve => {
663    let overlay = document.getElementById('styled-prompt-overlay');
664    if (!overlay) {
665      overlay = document.createElement('div');
666      overlay.id = 'styled-prompt-overlay';
667      overlay.className = 'modal';
668      overlay.innerHTML =
669        '<div class="modal-content styled-confirm-box styled-prompt-box" role="dialog" aria-modal="true" aria-labelledby="styled-prompt-title" aria-describedby="styled-prompt-msg">' +
670          '<div class="modal-header"><h4 id="styled-prompt-title"></h4></div>' +
671          '<div class="modal-body">' +
672            '<p id="styled-prompt-msg"></p>' +
673            '<input type="text" id="styled-prompt-input" class="styled-prompt-input" />' +
674          '</div>' +
675          '<div class="modal-footer">' +
676            '<button id="styled-prompt-cancel" class="confirm-btn confirm-btn-secondary"></button>' +
677            '<button id="styled-prompt-ok" class="confirm-btn confirm-btn-primary"></button>' +
678          '</div>' +
679        '</div>';
680      document.body.appendChild(overlay);
681    }
682
683    const titleEl = document.getElementById('styled-prompt-title');
684    const msgEl = document.getElementById('styled-prompt-msg');
685    const input = document.getElementById('styled-prompt-input');
686    const okBtn = document.getElementById('styled-prompt-ok');
687    const cancelBtn = document.getElementById('styled-prompt-cancel');
688
689    titleEl.textContent = title;
690    msgEl.textContent = message || '';
691    msgEl.style.display = message ? '' : 'none';
692    input.value = defaultValue || '';
693    input.placeholder = placeholder || '';
694    input.maxLength = maxLength;
695    okBtn.textContent = confirmText;
696    cancelBtn.textContent = cancelText;
697
698    // Remember what had focus so we can restore it when the dialog closes.
699    const _prevFocus = document.activeElement;
700    overlay.classList.remove('hidden');
701    overlay.style.display = '';
702
703    function cleanup(result) {
704      overlay.classList.add('hidden');
705      overlay.style.display = 'none';
706      okBtn.removeEventListener('click', onOk);
707      cancelBtn.removeEventListener('click', onCancel);
708      overlay.removeEventListener('click', onBackdrop);
709      document.removeEventListener('keydown', onKey);
710      input.removeEventListener('keydown', onInputKey);
711      try { _prevFocus && _prevFocus.focus && _prevFocus.focus(); } catch {}
712      resolve(result);
713    }
714    function onOk() { cleanup((input.value || '').trim()); }
715    function onCancel() { cleanup(null); }
716    function onBackdrop(e) { if (e.target === overlay) cleanup(null); }
717    function onKey(e) {
718      if (e.key === 'Escape') {
719        e.preventDefault();
720        e.stopPropagation();
721        e.stopImmediatePropagation();
722        cleanup(null);
723      } else if (e.key === 'Tab') {
724        // Trap focus inside the dialog (input → Cancel → OK → input …).
725        e.preventDefault();
726        const f = [input, cancelBtn, okBtn];
727        const i = f.indexOf(document.activeElement);
728        const n = e.shiftKey ? (i <= 0 ? f.length - 1 : i - 1) : (i >= f.length - 1 ? 0 : i + 1);
729        f[n].focus();
730      }
731    }
732    function onInputKey(e) {
733      if (e.key === 'Enter') {
734        e.preventDefault();
735        onOk();
736      }
737    }
738
739    okBtn.addEventListener('click', onOk);
740    cancelBtn.addEventListener('click', onCancel);
741    overlay.addEventListener('click', onBackdrop);
742    document.addEventListener('keydown', onKey);
743    input.addEventListener('keydown', onInputKey);
744
745    requestAnimationFrame(() => {
746      input.focus();
747      input.select();
748    });
749  });
750}
751
752// Lookup table for esc(); hoisted out of the replace callback so it is
753// allocated once rather than per matched character.
754const _ESC_MAP = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
755/**
756 * HTML-escape a string to prevent XSS.
757 * Canonical implementation — other modules should use uiModule.esc() instead of local copies.
758 */
759export function esc(s) {
760  return (s || '').replace(/[&<>"']/g, (m) => _ESC_MAP[m]);
761}
762
763// ── Mobile: suppress synthetic click/mousedown on backdrop ──
764// When a touch starts inside .modal-content, set a flag so that
765// synthetic mouse events on the backdrop are ignored.
766let _touchInsideModal = false;
767if ('ontouchstart' in window) {
768  document.addEventListener('touchstart', (e) => {
769    if (e.target.closest('.modal-content')) {
770      _touchInsideModal = true;
771    }
772  }, { passive: true });
773  document.addEventListener('touchend', () => {
774    // Clear after a short delay — synthetic click fires ~300ms after touchend
775    setTimeout(() => { _touchInsideModal = false; }, 400);
776  }, { passive: true });
777}
778
779/**
780 * Check if a backdrop dismiss should be suppressed on mobile.
781 * Other modules can call this to guard their own backdrop handlers.
782 */
783export function isTouchInsideModal() {
784  return _touchInsideModal;
785}
786
787// Close floating dropdowns/popups on scroll to prevent them drifting
788function _initScrollDismiss() {
789  const chatHistory = document.getElementById('chat-history');
790  if (chatHistory) {
791    chatHistory.addEventListener('scroll', () => {
792      chatHistory.querySelectorAll('.dropdown.show').forEach(d => d.classList.remove('show'));
793      document.querySelectorAll('.ctx-popup').forEach(dismissOrRemove);
794    }, { passive: true });
795  } else {
796    // Retry once if element doesn't exist yet
797    setTimeout(_initScrollDismiss, 500);
798  }
799}
800if (document.readyState === 'loading') {
801  document.addEventListener('DOMContentLoaded', _initScrollDismiss);
802} else {
803  _initScrollDismiss();
804}
805
806/**
807 * Returns the SVG string for an empty-state icon. `kind` is one of
808 * 'smiley' | 'sad' | 'neutral'. The returned <svg> has NO inline style —
809 * callers wrap with `<span style="vertical-align:-3px;margin-left:6px;">…</span>`
810 * (or similar) to keep the per-site visual nudge they need.
811 */
812export function emptyStateIcon(kind) {
813  const SVG_OPEN = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">';
814  const SVG_CLOSE = '</svg>';
815  let inner;
816  switch (kind) {
817    case 'sad':
818      inner = '<circle cx="12" cy="12" r="10"/><path d="M16 16s-1.5-2-4-2-4 2-4 2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/>';
819      break;
820    case 'neutral':
821      inner = '<circle cx="12" cy="12" r="10"/><line x1="8" y1="15" x2="16" y2="15"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/>';
822      break;
823    case 'smiley':
824    default:
825      inner = '<circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/>';
826      break;
827  }
828  return SVG_OPEN + inner + SVG_CLOSE;
829}
830
831const uiModule = {
832  copyToClipboard,
833  showToast,
834  showError,
835  styledConfirm,
836  styledPrompt,
837  scrollHistory,
838  scrollHistoryInstant,
839  setAutoScroll,
840  getAutoScroll,
841  autoResize,
842  debounce,
843  el,
844  esc,
845  isTouchInsideModal,
846  emptyStateIcon,
847  registerMenuDismiss
848};
849
850export default uiModule;
851
852// Expose the styled confirm globally so any module can replace the native
853// browser confirm() with the themed dialog — even files that don't import
854// uiModule. Usage: `if (!await window.styledConfirm(msg, { danger:true })) return;`
855if (typeof window !== 'undefined') {
856  window.styledConfirm = styledConfirm;
857}
858
859// ── Mobile: clear enter animation so inline transform works for dragging ──
860// The CSS `animation: sheet-enter ... forwards` holds the final transform,
861// blocking any inline style changes. We clear it once the animation completes.
862if ('ontouchstart' in window || window.innerWidth <= 768) {
863  document.addEventListener('animationend', (e) => {
864    if (e.animationName === 'sheet-enter' &&
865        (e.target.classList.contains('modal-content') || e.target.id === 'theme-popup')) {
866      e.target.classList.add('sheet-ready');
867    }
868  });
869  // When a modal is re-shown, remove sheet-ready so the enter animation plays again
870  new MutationObserver((mutations) => {
871    for (const m of mutations) {
872      if (m.type === 'attributes' && m.attributeName === 'class') {
873        const modal = m.target;
874        if (modal.classList.contains('modal') && !modal.classList.contains('hidden')) {
875          const content = modal.querySelector('.modal-content') || modal.querySelector('#theme-popup');
876          if (content) {
877            content.classList.remove('sheet-ready', 'modal-closing');
878          }
879        }
880      }
881    }
882  }).observe(document.body, { subtree: true, attributes: true, attributeFilter: ['class'] });
883}
884
885// ── Mobile swipe-down-to-dismiss for bottom sheet modals ──
886// Finger-following drag with velocity-based dismiss.
887// Works from grab handle, header, OR anywhere on the sheet when content is scrolled to top.
888if ('ontouchstart' in window) {
889  const DISMISS_THRESHOLD = 50;    // px — dismiss if dragged past this
890  const VELOCITY_THRESHOLD = 0.3;  // px/ms — fast flick dismisses even below threshold
891  const RUBBER_RESISTANCE = 0.35;  // drag resistance when pulling up past origin
892
893  let _swipeTarget = null;
894  let _startY = 0, _startX = 0;
895  let _lastY = 0, _lastT = 0;
896  let _velocity = 0;
897  let _dragging = false;    // true once we've committed to a vertical drag
898  let _cancelled = false;   // true if horizontal movement detected
899
900  // Close any floating dropdowns/menus that hang off body via position:fixed.
901  // Called when a swipe-dismiss gesture starts so the menu doesn't orphan over
902  // the page after the sheet slides away.
903  function _closeFloatingDropdownsForSwipe() {
904    document.querySelectorAll(
905      '.email-card-dropdown, .hwfit-cached-dropdown, .cookbook-saved-menu, .cookbook-dep-menu'
906    ).forEach(d => {
907      if (d._anchor) d._anchor.classList.remove('cookbook-menu-active', 'reader-more-active');
908      // Registered menus tear down through their own dismiss (releasing the
909      // Escape-stack entry);
909 unregistered ones (email/dep) just get removed.
910      dismissOrRemove(d);
911    });
912  }
913
914  document.addEventListener('touchstart', (e) => {
915    // Match .modal-content or #theme-popup (which acts as modal-content but uses its own ID)
916    const content = e.target.closest('.modal-content') || e.target.closest('#theme-popup');
917    if (!content) return;
918
919    // The image editor owns all touches inside its container so the user
920    // can paint / move layers / draw selections without the modal trying
921    // to interpret it as a swipe-to-dismiss gesture. Skip the swipe init
922    // entirely when the touch starts inside the editor area.
923    if (e.target.closest('.gallery-editor, .gallery-editor-container')) return;
924    // Internal vertical drag handles (e.g. the calendar's cal-splitter that
925    // resizes the day-detail pane) consume vertical touches themselves. If
926    // we don't bail here, the swipe-dismiss path also tracks the touch and
927    // slides the whole modal down as the user drags the handle. The
928    // [data-no-swipe-dismiss] hook lets other components opt out the same
929    // way without having to hard-code their selector here.
930    if (e.target.closest('.cal-splitter, [data-no-swipe-dismiss]')) return;
931
932    // Only allow swipe-dismiss from header or grab handle (top 48px)
933    const isHeader = !!e.target.closest('.modal-header');
934    const isButton = !!e.target.closest('button, input, select, label');
935    if (isHeader && isButton) return; // let button clicks through
936    const touch = e.touches[0];
937    const contentRect = content.getBoundingClientRect();
938    const isGrabZone = (touch.clientY - contentRect.top) < 48;
939    // Also allow swipe-dismiss from anywhere on the sheet when it's already
940    // scrolled to the top — feels natural and matches iOS bottom-sheet UX.
941    const isAtScrollTop = content.scrollTop <= 0;
942
943    if (!isHeader && !isGrabZone && !isAtScrollTop) return; // body touches → let native scroll handle it
944
945    _swipeTarget = content;
946    // Ensure CSS animation is cleared so inline transform works
947    content.classList.add('sheet-ready');
948    content.style.animation = 'none';
949    _startY = touch.clientY;
950    _startX = touch.clientX;
951    _lastY = _startY;
952    _lastT = e.timeStamp;
953    _velocity = 0;
954    _dragging = false;
955    _cancelled = false;
956  }, { passive: true });
957
958  document.addEventListener('touchmove', (e) => {
959    if (!_swipeTarget || _cancelled) return;
960    const touch = e.touches[0];
961    const dx = Math.abs(touch.clientX - _startX);
962    const dy = touch.clientY - _startY;
963
964    // First few pixels: decide if this is horizontal scroll or content scroll
965    if (!_dragging) {
966      if (dx > 40 && dx > Math.abs(dy) * 2) {
967        _swipeTarget.style.transform = '';
968        _swipeTarget = null;
969        _cancelled = true;
970        return;
971      }
972      if (Math.abs(dy) > 8) {
973        // Find the nearest scrollable ancestor of the touch point
974        let scrollEl = e.target;
975        while (scrollEl && scrollEl !== _swipeTarget) {
976          if (scrollEl.scrollHeight > scrollEl.clientHeight + 1) {
977            const ov = getComputedStyle(scrollEl).overflowY;
978            if (ov === 'auto' || ov === 'scroll') break;
979          }
980          scrollEl = scrollEl.parentElement;
981        }
982        const hasScroller = scrollEl && scrollEl !== _swipeTarget;
983        // If touch is inside a scrollable child, let native scroll handle it
984        if (hasScroller) {
985          _swipeTarget.style.transform = '';
986          _swipeTarget = null;
987          _cancelled = true;
988          return;
989        }
990        // If swiping up and modal-content itself is scrollable, let native handle it
991        if (dy < 0 && _swipeTarget.scrollHeight > _swipeTarget.clientHeight + 1) {
992          _swipeTarget.style.transform = '';
993          _swipeTarget = null;
994          _cancelled = true;
995          return;
996        }
997        // If swiping down but content isn't at the top, let native scroll
998        if (dy > 0 && _swipeTarget.scrollTop > 0) {
999          _swipeTarget.style.transform = '';
1000          _swipeTarget = null;
1001          _cancelled = true;
1002          return;
1003        }
1004        _dragging = true;
1005        _swipeTarget.style.transition = 'none';
1006        _swipeTarget.style.willChange = 'transform';
1007        // A swipe is starting — close any floating menus/dropdowns so they
1008        // don't orphan over the page once the sheet slides away. Covers the
1009        // email reader More menu, cookbook serve kebab + saved-configs, and
1010        // anything else hanging off body via _anchor.
1011        _closeFloatingDropdownsForSwipe();
1012      } else {
1013        return;
1014      }
1015    }
1016
1017    // Track velocity (exponential moving average)
1018    const dt = e.timeStamp - _lastT;
1019    if (dt > 0) {
1020      const instantV = (touch.clientY - _lastY) / dt;
1021      _velocity = _velocity * 0.6 + instantV * 0.4;
1022    }
1023    _lastY = touch.clientY;
1024    _lastT = e.timeStamp;
1025
1026    e.preventDefault();
1027    if (dy > 0) {
1028      _swipeTarget.style.transform = `translateY(${dy}px)`;
1029    } else {
1030      const rubberDy = dy * RUBBER_RESISTANCE;
1031      _swipeTarget.style.transform = `translateY(${rubberDy}px)`;
1032    }
1033  }, { passive: false });
1034
1035  document.addEventListener('touchend', (e) => {
1036    if (!_swipeTarget || !_dragging) {
1037      _swipeTarget = null;
1038      return;
1039    }
1040    const el = _swipeTarget;
1041    _swipeTarget = null;
1042
1043    const dy = _lastY - _startY;
1044    const shouldDismiss = dy > DISMISS_THRESHOLD || (dy > 20 && _velocity > VELOCITY_THRESHOLD);
1045
1046    el.style.willChange = '';
1047
1048    if (shouldDismiss) {
1049      // Animate out — use remaining distance to calculate duration
1050      const remaining = el.offsetHeight - dy;
1051      const speed = Math.max(Math.abs(_velocity), 0.8); // min speed
1052      const duration = Math.min(Math.max(remaining / speed, 120), 300);
1053      el.style.transition = `transform ${duration}ms cubic-bezier(0.2, 0, 0.4, 1)`;
1054      el.style.transform = 'translateY(100%)';
1055      setTimeout(() => {
1056        const modal = el.closest('.modal');
1057        if (modal) {
1058          modal.classList.add('hidden');
1059          // Some modals (calendar, email library) toggle visibility via
1060          // inline display style which would override .hidden — clear it
1061          // so the modal is actually dismissed.
1062          modal.style.display = '';
1063          document.querySelectorAll('#settings-menu-list .list-item.active').forEach(i => i.classList.remove('active'));
1064          // Notify modules so they can sync internal open-state flags
1065          window.dispatchEvent(new CustomEvent('modal-dismissed', { detail: { id: modal.id } }));
1066          // Swiping a tool away to reveal a new/empty chat replays the welcome
1067          // "splash" reveal — the same nice effect notes gives on dismiss.
1068          // Only when the welcome screen is already the active state (new chat),
1069          // so we never cover a chat that has messages.
1070          const ws = document.getElementById('welcome-screen');
1071          if (ws && !ws.classList.contains('hidden')) {
1072            window.chatModule?.showWelcomeScreen?.();
1073          }
1074        }
1075        el.classList.remove('sheet-ready');
1076        el.style.transform = '';
1077        el.style.transition = '';
1078        el.style.animation = '';
1079      }, duration + 10);
1080    } else {
1081      // Snap back with spring-like easing
1082      el.style.transition = 'transform 0.25s cubic-bezier(0.2, 0.9, 0.3, 1.05)';
1083      el.style.transform = '';
1084      setTimeout(() => { el.style.transition = ''; el.style.animation = ''; }, 260);
1085    }
1086  }, { passive: true });
1087}
1088
1089// ---- Bring modal to front on click ----
1090{
1091  let topModalZ = 250;
1092  document.addEventListener('mousedown', (e) => {
1093    const modalContent = e.target.closest('.modal-content');
1094    if (!modalContent) return;
1095    const modal = modalContent.closest('.modal');
1096    if (!modal) return;
1097    topModalZ += 1;
1098    modal.style.zIndex = topModalZ;
1099  });
1100
1101  // Backdrop tap to close — delegated for all modals
1102  document.addEventListener('mousedown', (e) => {
1103    if (_touchInsideModal) return; // suppress synthetic events from content scrolling
1104    if (!e.target.classList.contains('modal')) return;
1105    const modal = e.target;
1106    if (modal.classList.contains('hidden')) return;
1107    const content = modal.querySelector('.modal-content');
1108    if (content) {
1109      content.classList.add('modal-closing');
1110      content.addEventListener('animationend', () => {
1111        modal.classList.add('hidden');
1112        content.classList.remove('modal-closing');
1113      }, { once: true });
1114      setTimeout(() => {
1115        if (!modal.classList.contains('hidden')) {
1116          modal.classList.add('hidden');
1117          content.classList.remove('modal-closing');
1118        }
1119      }, 300);
1120    } else {
1121      modal.classList.add('hidden');
1122    }
1123  });
1124}
1125
1126// ── Mobile: keep focused inputs visible above the keyboard ──
1127// When an input inside a modal gets focus on mobile, the OS keyboard
1128// covers the bottom half of the screen. The browser is supposed to
1129// scroll the input into view, but in bottom-sheet modals with their
1130// own scrolling container that often fails — the user types blind.
1131// Scroll the input into the middle of the still-visible viewport
1132// after the keyboard has had a moment to animate in.
1133if ('ontouchstart' in window || window.innerWidth <= 768) {
1134  let _kbScrollTimer = null;
1135  document.addEventListener('focusin', (e) => {
1136    const el = e.target;
1137    if (!el || el.nodeType !== 1) return;
1138    const tag = el.tagName;
1139    const isText = tag === 'INPUT' || tag === 'TEXTAREA' ||
1140                   (tag === 'DIV' && el.isContentEditable);
1141    if (!isText) return;
1142    // Inputs of type button/checkbox/radio/range/etc. don't summon a keyboard
1143    if (tag === 'INPUT') {
1144      const t = (el.type || 'text').toLowerCase();
1145      if (['button','submit','reset','checkbox','radio','range','color','file','image'].includes(t)) return;
1146    }
1147    if (_kbScrollTimer) clearTimeout(_kbScrollTimer);
1148    // The keyboard typically takes 200–300ms to slide up; do the scroll
1149    // after that so we know the final visible viewport height.
1150    _kbScrollTimer = setTimeout(() => {
1151      _kbScrollTimer = null;
1152      // Skip the scroll if the input is already visible inside the
1153      // current viewport (with a small comfort margin). Otherwise every
1154      // re-focus — including the programmatic refocus that happens when
1155      // a typeahead input rebuilds the DOM on every keystroke — would
1156      // re-scroll the modal and yank the page up and down as the user
1157      // types.
1158      try {
1159        const r = el.getBoundingClientRect();
1160        const vh = (window.visualViewport?.height) || window.innerHeight;
1161        const margin = 24;
1162        const fullyVisible = r.top >= margin && r.bottom <= vh - margin;
1163        if (fullyVisible) return;
1164        el.scrollIntoView({ block: 'center', behavior: 'smooth' });
1165      } catch {
1166        try { el.scrollIntoView(); } catch {}
1167      }
1168    }, 300);
1169  });
1170}
1171
1172// ── Global Escape arbiter: close exactly one thing per press ──
1173// Priority: expanded library card → open chat thinking block → topmost modal.
1174// Runs capture-phase + stopImmediatePropagation so per-modal ESC listeners
1175// never also fire (which would otherwise close several modals at once).
1176if (!window._odyEscExpandGuard) {
1177  window._odyEscExpandGuard = true;
1178
1179  // Auto-promote any modal that becomes visible to the top of the z-stack.
1180  // Every modal shares `z-index: 250` from the base `.modal` rule, so visual
1181  // stacking falls back to DOM order — which is unpredictable (cookbook is
1182  // a static HTML node, calendar gets appended once and stays, compare and
1183  // research get re-appended on each open). Result: opening compare AFTER
1184  // cookbook can render compare UNDER it. Bumping the z-index on every
1185  // open guarantees most-recently-opened wins both visually AND for ESC.
1186  let _zCounter = 1000;
1187  const _isVisible = (m) => !m.classList.contains('hidden') && getComputedStyle(m).display !== 'none';
1188  const _promote = (m) => {
1189    if (!m?.classList?.contains('modal') || !_isVisible(m)) return;
1190    // Re-entry guard: setting style.zIndex itself fires the observer that
1191    // calls us back. Skip if this element is already pinned to the top
1192    // (matches the current counter) so we don't spin into an infinite loop.
1193    const cur = parseInt(m.style.zIndex, 10) || 0;
1194    if (cur === _zCounter) return;
1195    m.style.zIndex = String(++_zCounter);
1196  };
1197  new MutationObserver((muts) => {
1198    for (const m of muts) {
1199      if (m.type === 'childList') m.addedNodes.forEach(n => n.nodeType === 1 && _promote(n));
1200      else if (m.type === 'attributes' && m.target?.classList?.contains('modal')) _promote(m.target);
1201    }
1202  }).observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['class', 'style'] });
1203  document.querySelectorAll('.modal').forEach(_promote);
1204
1205  const pickTopModal = () => {
1206    const modals = [...document.querySelectorAll('.modal')].filter(_isVisible);
1207    if (!modals.length) return null;
1208    return modals.reduce((top, m) =>
1209      (parseInt(getComputedStyle(m).zIndex, 10) || 0) >= (parseInt(getComputedStyle(top).zIndex, 10) || 0)
1210        ? m : top
1211    );
1212  };
1213
1214  document.addEventListener('keydown', (e) => {
1215    if (e.key !== 'Escape' || e.defaultPrevented) return;
1216
1217    // Find the single thing to close, in priority order. The first hit wins.
1218    // Important: if a thinking block is open we MUST handle it ourselves and
1219    // not fall through to closing a modal — even if its header is missing
1220    // (the live-stream chat rebuilds thinking DOM mid-stream so the header
1221    // can briefly be absent). Toggling the `expanded` class directly is the
1222    // fallback so ESC never bypasses the thinking block to hit a modal.
1223    if (_closeHoveredWindow()) {
1224      e.stopImmediatePropagation(); e.preventDefault();
1225      return;
1226    }
1227    // Transient ad-hoc menus (dropdowns / context popups) live outside the
1228    // .modal system and register a dismiss callback in escMenuStack. Close the
1229    // most-recently-opened one first — so a menu opened over a modal dismisses
1230    // before the modal — and do it BEFORE the text-input guard below, since a
1231    // menu may own the focused input (e.g. a search dropdown).
1232    if (dismissTopMenu()) {
1233      e.stopImmediatePropagation(); e.preventDefault();
1234      return;
1235    }
1236    const t = e.target;
1237    if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
1238    const expanded = document.querySelector('.doclib-card-expanded');
1239    const think = document.querySelector('.thinking-content.expanded');
1240    if (expanded) {
1241      e.stopImmediatePropagation(); e.preventDefault();
1242      try { expanded.click(); } catch {}
1243      return;
1244    }
1245    if (think) {
1246      e.stopImmediatePropagation(); e.preventDefault();
1247      const thinkHeader = think.closest('.thinking-section')?.querySelector('.thinking-header[data-thinking-id]');
1248      if (thinkHeader) { try { thinkHeader.click(); } catch {} }
1249      else {
1250        // No header found — collapse the content directly.
1251        try { think.classList.remove('expanded'); } catch {}
1252      }
1253      return;
1254    }
1255    const galleryEditor = document.getElementById('gallery-editor-container');
1256    const galleryModal = galleryEditor?.closest('.modal');
1257    const galleryEditing = !!(
1258      galleryEditor &&
1259      galleryModal &&
1260      !galleryModal.classList.contains('hidden') &&
1261      getComputedStyle(galleryEditor).display !== 'none' &&
1262      galleryEditor.querySelector('.gallery-editor')
1263    );
1264    if (galleryEditing) {
1265      e.preventDefault();
1266      e.stopImmediatePropagation();
1267      return;
1268    }
1269    const settingsModal = document.getElementById('settings-modal');
1270    if (settingsModal && _isVisible(settingsModal)) {
1271      const innerForm = settingsModal.querySelector('#unified-intg-form, #set-email-accounts-form');
1272      if (innerForm && innerForm.style.display !== 'none' && innerForm.children.length > 0) {
1273        e.preventDefault();
1274        e.stopImmediatePropagation();
1275        innerForm.style.display = 'none';
1276        innerForm.innerHTML = '';
1277        return;
1278      }
1279    }
1280    const topModal = pickTopModal();
1281    if (!topModal) return;
1282    const closeBtn = topModal.querySelector('.close-btn, .modal-close-btn, [data-action="close"]');
1283    e.stopImmediatePropagation();
1284    e.preventDefault();
1285    if (closeBtn) { try { closeBtn.click(); } catch {} }
1286    else { try { topModal.classList.add('hidden'); } catch {} }
1287  }, true);
1288}

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.