diff --git a/static/js/calendar.js b/static/js/calendar.js index a6d258c..bea1ca0 100644 --- a/static/js/calendar.js +++ b/static/js/calendar.js @@ -7,6 +7,7 @@ import spinnerModule from './spinner.js'; import * as Modals from './modalManager.js'; import { makeWindowDraggable } from './windowDrag.js'; import { attachColorPicker } from './colorPicker.js'; +import { bindMenuDismiss } from './escMenuStack.js'; import { WEEKDAYS, MONTHS, MON_SHORT, CAL_PALETTE, CAL_COLORS, _CAL_CUSTOM_GRADIENT, _TYPE_PALETTE, @@ -426,9 +427,10 @@ function _clampDropdown(dropdown, anchorRect) { } function _showEventMoreMenu(ev, anchor) { - document.querySelectorAll('.cal-event-dropdown').forEach(d => d.remove()); + document.querySelectorAll('.cal-event-dropdown').forEach(d => { if (typeof d._dismiss === 'function') d._dismiss(); else d.remove(); }); const dropdown = document.createElement('div'); dropdown.className = 'cal-event-dropdown'; + let closeMenu = () => dropdown.remove(); const rect = anchor.getBoundingClientRect(); dropdown.style.cssText = `position:fixed;z-index:10001;min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;left:0px;visibility:hidden;`; @@ -443,12 +445,12 @@ function _showEventMoreMenu(ev, anchor) { const _editIcon = ''; dropdown.appendChild(_item(_editIcon, 'Edit', () => { - dropdown.remove(); + closeMenu(); _showEventForm(ev); })); dropdown.appendChild(_item(_trashIcon, 'Delete', async () => { - dropdown.remove(); + closeMenu(); const name = ev.summary ? `"${ev.summary}"` : 'this event'; const ok = await uiModule.styledConfirm(`Delete ${name}?`, { confirmText: 'Delete', danger: true }); if (!ok) return; @@ -459,14 +461,7 @@ function _showEventMoreMenu(ev, anchor) { dropdown._anchorRect = rect; _clampDropdown(dropdown, rect); dropdown.style.visibility = ''; - const close = (ev2) => { - if (!dropdown.contains(ev2.target) && ev2.target !== anchor) { - dropdown.remove(); - document.removeEventListener('click', close, true); - } - }; - setTimeout(() => document.addEventListener('click', close, true), 10); -} + closeMenu = bindMenuDismiss(dropdown, () => dropdown.remove(), (ev2) => !dropdown.contains(ev2.target) && ev2.target !== anchor);} async function _createEventReminder(ev, dueDate) { // Store the reminder as an absolute UTC instant (with the Z suffix) so the diff --git a/static/js/chatRenderer.js b/static/js/chatRenderer.js index 73b2eb6..5c18e74 100644 --- a/static/js/chatRenderer.js +++ b/static/js/chatRenderer.js @@ -7,6 +7,7 @@ import { addAITTSButton } from './tts-ai.js'; import { providerLogo } from './providers.js'; import settingsModule from './settings.js'; import spinnerModule from './spinner.js'; +import { bindMenuDismiss } from './escMenuStack.js'; const SEARCH_ICON = ''; const REPORT_ICON = ''; @@ -568,7 +569,7 @@ export function applyModelColor(roleEl, modelName) { roleEl.style.cursor = 'pointer'; roleEl.addEventListener('click', (e) => { e.stopPropagation(); - document.querySelectorAll('.ctx-popup').forEach(p => p.remove()); + document.querySelectorAll('.ctx-popup').forEach(p => { if (typeof p._dismiss === 'function') p._dismiss(); else p.remove(); }); const info = getModelInfo(modelName); const short = shortModel(modelName); const logoHtml = providerLogo(modelName); @@ -626,10 +627,7 @@ export function applyModelColor(roleEl, modelName) { const pr = popup.getBoundingClientRect(); if (pr.bottom > window.innerHeight - 8) popup.style.top = (rect.top - pr.height - 4) + 'px'; if (pr.right > window.innerWidth - 8) popup.style.left = (window.innerWidth - pr.width - 8) + 'px'; - const closePopup = (ev) => { - if (!popup.contains(ev.target)) { popup.remove(); document.removeEventListener('click', closePopup, true); } - }; - setTimeout(() => document.addEventListener('click', closePopup, true), 0); + bindMenuDismiss(popup, () => popup.remove()); }); } } @@ -1332,12 +1330,17 @@ export function createMsgFooter(msgElement) { moreBtn.textContent = '\u00B7\u00B7\u00B7'; moreBtn.addEventListener('click', (e) => { e.stopPropagation(); - // Toggle overflow menu — close any existing one first + // Toggle overflow menu — close any existing one first (through its own + // dismiss so the Escape registry entry goes with it). const existing = document.querySelector('.msg-overflow-menu'); - if (existing) { existing.remove(); if (existing._trigger === moreBtn) return; } + if (existing) { + if (typeof existing._dismiss === 'function') existing._dismiss(); else existing.remove(); + if (existing._trigger === moreBtn) return; + } const menu = document.createElement('div'); menu.className = 'msg-overflow-menu'; + let closeMenu = () => menu.remove(); overflow.forEach(a => { const item = document.createElement('button'); item.className = 'msg-overflow-item'; @@ -1347,7 +1350,7 @@ export function createMsgFooter(msgElement) { item.addEventListener('click', (ev) => { ev.stopPropagation(); _trackAction(a.id); - menu.remove(); + closeMenu(); a.handler(ev); }); menu.appendChild(item); @@ -1363,15 +1366,9 @@ export function createMsgFooter(msgElement) { // Keep within right edge const mr = menu.getBoundingClientRect(); if (mr.right > window.innerWidth - 8) menu.style.left = (window.innerWidth - mr.width - 8) + 'px'; - // Close on outside click - const close = (ev) => { - if (!menu.contains(ev.target) && ev.target !== moreBtn) { - menu.remove(); - document.removeEventListener('click', close, true); - } - }; - setTimeout(() => document.addEventListener('click', close, true), 0); - }); + // Close on outside click or Escape. The trigger button is treated as + // "inside" so its own click toggles rather than double-fires. + closeMenu = bindMenuDismiss(menu, () => menu.remove(), (ev) => !menu.contains(ev.target) && ev.target !== moreBtn); }); actions.appendChild(moreBtn); } @@ -1392,9 +1389,14 @@ export function createMsgFooter(msgElement) { pill.addEventListener('click', (e) => { e.stopPropagation(); let detail = pill._openDetail || document.querySelector('.memory-used-detail'); - if (detail) { detail.remove(); pill._openDetail = null; return; } + if (detail) { + if (typeof detail._dismiss === 'function') detail._dismiss(); + else { detail.remove(); pill._openDetail = null; } + return; + } detail = document.createElement('div'); detail.className = 'memory-used-detail'; + let closeDetail = () => { detail.remove(); pill._openDetail = null; }; mems.forEach(m => { const row = document.createElement('div'); row.className = 'memory-used-row'; @@ -1410,8 +1412,7 @@ export function createMsgFooter(msgElement) { row.appendChild(text); row.addEventListener('click', (ev) => { ev.stopPropagation(); - detail.remove(); - pill._openDetail = null; + closeDetail(); const memModal = document.getElementById('memory-modal'); if (memModal) memModal.classList.remove('hidden'); }); @@ -1435,15 +1436,8 @@ export function createMsgFooter(msgElement) { if (parseFloat(detail.style.left) < 8) detail.style.left = '8px'; detail.style.visibility = ''; pill._openDetail = detail; - const close = (ev) => { - if (!detail.contains(ev.target) && ev.target !== pill) { - detail.remove(); - pill._openDetail = null; - document.removeEventListener('click', close, true); - } - }; - setTimeout(() => document.addEventListener('click', close, true), 0); - }); + // Close on outside click or Escape (pill click toggles, so it's inside). + closeDetail = bindMenuDismiss(detail, () => { detail.remove(); pill._openDetail = null; }, (ev) => !detail.contains(ev.target) && ev.target !== pill); }); footer.appendChild(pill); } @@ -1528,10 +1522,14 @@ export function createUserMsgFooter(msgElement) { moreBtn.addEventListener('click', (e) => { e.stopPropagation(); const existing = document.querySelector('.msg-overflow-menu'); - if (existing) { existing.remove(); if (existing._trigger === moreBtn) return; } + if (existing) { + if (typeof existing._dismiss === 'function') existing._dismiss(); else existing.remove(); + if (existing._trigger === moreBtn) return; + } const menu = document.createElement('div'); menu.className = 'msg-overflow-menu'; + let closeMenu = () => menu.remove(); overflow.forEach(a => { const item = document.createElement('button'); item.className = 'msg-overflow-item'; @@ -1541,7 +1539,7 @@ export function createUserMsgFooter(msgElement) { item.addEventListener('click', (ev) => { ev.stopPropagation(); _trackUserAction(a.id); - menu.remove(); + closeMenu(); a.handler(ev); }); menu.appendChild(item); @@ -1554,14 +1552,7 @@ export function createUserMsgFooter(msgElement) { if (parseFloat(menu.style.top) < 8) menu.style.top = (btnRect.bottom + 4) + 'px'; const mr = menu.getBoundingClientRect(); if (mr.right > window.innerWidth - 8) menu.style.left = (window.innerWidth - mr.width - 8) + 'px'; - const close = (ev) => { - if (!menu.contains(ev.target) && ev.target !== moreBtn) { - menu.remove(); - document.removeEventListener('click', close, true); - } - }; - setTimeout(() => document.addEventListener('click', close, true), 0); - }); + closeMenu = bindMenuDismiss(menu, () => menu.remove(), (ev) => !menu.contains(ev.target) && ev.target !== moreBtn); }); actions.appendChild(moreBtn); } @@ -1625,7 +1616,7 @@ export function displayMetrics(messageElement, metrics) { metricsDivider.style.pointerEvents = 'none'; metricsContainer.addEventListener('click', (e) => { e.stopPropagation(); - document.querySelectorAll('.ctx-popup').forEach(p => p.remove()); + document.querySelectorAll('.ctx-popup').forEach(p => { if (typeof p._dismiss === 'function') p._dismiss(); else p.remove(); }); const costStr = cost !== null ? `$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}` : 'n/a'; const speedStr = tps != null && tps !== 'undefined' ? `${tps} tok/s` : 'n/a'; @@ -1685,13 +1676,7 @@ export function displayMetrics(messageElement, metrics) { if (parseFloat(popup.style.left) < 8) popup.style.left = '8px'; popup.style.visibility = ''; - const closePopup = (ev) => { - if (!popup.contains(ev.target)) { - popup.remove(); - document.removeEventListener('click', closePopup, true); - } - }; - setTimeout(() => document.addEventListener('click', closePopup, true), 0); + bindMenuDismiss(popup, () => popup.remove()); }); // Store real context length for model info popup @@ -1722,7 +1707,7 @@ export function displayMetrics(messageElement, metrics) { ctxRing.addEventListener('click', (e) => { e.stopPropagation(); - document.querySelectorAll('.ctx-detail-popup').forEach(p => p.remove()); + document.querySelectorAll('.ctx-detail-popup').forEach(p => { if (typeof p._dismiss === 'function') p._dismiss(); else p.remove(); }); const usedTokens = inputTokens || 0; const totalCtx = ctxLen || 0; @@ -1826,13 +1811,7 @@ export function displayMetrics(messageElement, metrics) { } popup.style.visibility = ''; - const closePopup = (ev) => { - if (!popup.contains(ev.target) && ev.target !== ctxRing && !ctxRing.contains(ev.target)) { - popup.remove(); - document.removeEventListener('click', closePopup, true); - } - }; - setTimeout(() => document.addEventListener('click', closePopup, true), 0); + bindMenuDismiss(popup, () => popup.remove(), (ev) => !popup.contains(ev.target) && ev.target !== ctxRing && !ctxRing.contains(ev.target)); }); } diff --git a/static/js/compare/panes.js b/static/js/compare/panes.js index 226d8f2..fe03bad 100644 --- a/static/js/compare/panes.js +++ b/static/js/compare/panes.js @@ -10,6 +10,7 @@ import { _clearProbeWaves } from './probe.js'; import Storage from '../storage.js'; import uiModule from '../ui.js'; import spinnerModule from '../spinner.js'; +import { bindMenuDismiss } from '../escMenuStack.js'; var escapeHtml = uiModule.esc; @@ -282,10 +283,11 @@ async function _addPane(anchorBtn) { // Toggle existing dropdown const existing = document.querySelector('.add-pane-dropdown'); - if (existing) { existing.remove(); return; } + if (existing) { if (typeof existing._dismiss === 'function') existing._dismiss(); else existing.remove(); return; } const dropdown = document.createElement('div'); dropdown.className = 'add-pane-dropdown'; + let closeMenu = () => dropdown.remove(); // Search input for large model lists if (filtered.length >= 5) { @@ -326,7 +328,7 @@ async function _addPane(anchorBtn) { item.addEventListener('click', async (e) => { e.stopPropagation(); - dropdown.remove(); + closeMenu(); await _createAndAppendPane(m); }); dropdown.appendChild(item); @@ -371,15 +373,8 @@ async function _addPane(anchorBtn) { dropdown.style.bottom = 'auto'; dropdown.style.maxHeight = Math.min(ddH, vh - margin * 2) + 'px'; - // Close on outside click - const close = (e) => { - if (!dropdown.contains(e.target) && e.target !== anchorBtn) { - dropdown.remove(); - document.removeEventListener('click', close); - } - }; - setTimeout(() => document.addEventListener('click', close), 0); -} + // Close on outside click or Escape (the latter via the registry). + closeMenu = bindMenuDismiss(dropdown, () => dropdown.remove(), (e) => !dropdown.contains(e.target) && e.target !== anchorBtn);} /** Create a new pane for the given model and append it to the compare grid. */ async function _createAndAppendPane(m) { @@ -551,7 +546,7 @@ function _showModelSwapDropdown(paneIdx, titleBtn) { // Remove any existing dropdown const existing = document.querySelector('.pane-model-dropdown'); - if (existing) { existing.remove(); return; } + if (existing) { if (typeof existing._dismiss === 'function') existing._dismiss(); else existing.remove(); return; } const _effectiveType = (state._compareMode === 'agent' || state._compareMode === 'research') ? 'chat' : state._compareMode; const filtered = state._cachedModels.filter(m => m.type === _effectiveType); @@ -559,6 +554,7 @@ function _showModelSwapDropdown(paneIdx, titleBtn) { const dropdown = document.createElement('div'); dropdown.className = 'pane-model-dropdown'; + let closeMenu = () => dropdown.remove(); filtered.forEach(m => { const item = document.createElement('button'); @@ -573,7 +569,7 @@ function _showModelSwapDropdown(paneIdx, titleBtn) { } item.addEventListener('click', async (e) => { e.stopPropagation(); - dropdown.remove(); + closeMenu(); // Update the model for this pane and persist state._selectedModels[paneIdx] = { @@ -653,15 +649,8 @@ function _showModelSwapDropdown(paneIdx, titleBtn) { dropdown.style.top = top + 'px'; dropdown.style.maxHeight = Math.min(ddH, vh - margin * 2) + 'px'; - // Close on outside click - const close = (e) => { - if (!dropdown.contains(e.target) && e.target !== titleBtn) { - dropdown.remove(); - document.removeEventListener('click', close); - } - }; - setTimeout(() => document.addEventListener('click', close), 0); -} + // Close on outside click or Escape (the latter via the registry). + closeMenu = bindMenuDismiss(dropdown, () => dropdown.remove(), (e) => !dropdown.contains(e.target) && e.target !== titleBtn);} // ── Shuffle / reset ── diff --git a/static/js/cookbookRunning.js b/static/js/cookbookRunning.js index 3f8e591..c242133 100644 --- a/static/js/cookbookRunning.js +++ b/static/js/cookbookRunning.js @@ -6,6 +6,7 @@ import uiModule from './ui.js'; import { _diagnose, _showDiagnosis, _clearDiagnosis } from './cookbook-diagnosis.js'; +import { registerMenuDismiss } from './escMenuStack.js'; // Human-friendly badge label for a task's internal status. Avoids surfacing // the word "error" in the sidebar — a server the user stopped or one that @@ -1546,7 +1547,7 @@ export function _renderRunningTab() { el.addEventListener('touchcancel', _lpCancel, { passive: true }); menuBtn.addEventListener('click', (e) => { e.stopPropagation(); - document.querySelectorAll('.cookbook-task-dropdown').forEach(d => d.remove()); + document.querySelectorAll('.cookbook-task-dropdown').forEach(d => { if (typeof d._dismiss === 'function') d._dismiss(); else d.remove(); }); const dropdown = document.createElement('div'); dropdown.className = 'cookbook-task-dropdown'; @@ -1696,7 +1697,7 @@ export function _renderRunningTab() { const ic = _MENU_ICONS[item.action] || ''; div.innerHTML = `${ic}${item.label}`; div.addEventListener('click', () => { - dropdown.remove(); + _cleanup(); if (item.custom) { item.custom(); return; } el.querySelector('.cookbook-task-action-' + item.action)?.click(); }); @@ -1736,17 +1737,21 @@ export function _renderRunningTab() { // fixed position no longer matches the originating ⋮ button, so // it visually drifts. Matches the email kebab behaviour. const scrollClose = () => _cleanup(); + let _unreg = () => {}; const _cleanup = () => { + _unreg(); _unreg = () => {}; dropdown.remove(); document.removeEventListener('click', closeHandler); window.removeEventListener('scroll', scrollClose, true); window.visualViewport?.removeEventListener('scroll', scrollClose); }; + dropdown._dismiss = _cleanup; setTimeout(() => { document.addEventListener('click', closeHandler); window.addEventListener('scroll', scrollClose, true); window.visualViewport?.addEventListener('scroll', scrollClose); }, 0); + _unreg = registerMenuDismiss(_cleanup); }); } diff --git a/static/js/cookbookServe.js b/static/js/cookbookServe.js index 8ee8c5c..5c72d97 100644 --- a/static/js/cookbookServe.js +++ b/static/js/cookbookServe.js @@ -8,6 +8,7 @@ import uiModule from './ui.js'; import spinnerModule from './spinner.js'; import { providerLogo } from './providers.js'; import { modelColor } from './chatRenderer.js'; +import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js'; // Shared state/functions injected by init() let _envState; @@ -193,18 +194,19 @@ function _rerenderCachedModels() { list.querySelectorAll('.hwfit-cached-menu-btn').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); - // Toggle: if a dropdown for THIS button is already open, close it. + // Toggle: if a dropdown for THIS button is already open, close it + // (through its own dismiss so the Escape-stack entry goes with it). const existing = document.querySelector('.hwfit-cached-dropdown'); if (existing && existing._anchor === btn) { - existing.remove(); - btn.classList.remove('cookbook-menu-active'); + if (typeof existing._dismiss === 'function') existing._dismiss(); + else { existing.remove(); btn.classList.remove('cookbook-menu-active'); } return; } // Otherwise close any other open menu (and clear its anchor's active // state) before opening fresh. document.querySelectorAll('.hwfit-cached-dropdown').forEach(d => { if (d._anchor) d._anchor.classList.remove('cookbook-menu-active'); - d.remove(); + if (typeof d._dismiss === 'function') d._dismiss(); else d.remove(); }); const item = btn.closest('.memory-item'); const repo = item?.dataset.repo; @@ -215,6 +217,9 @@ function _rerenderCachedModels() { dropdown.className = 'hwfit-cached-dropdown'; dropdown._anchor = btn; btn.classList.add('cookbook-menu-active'); + // Shared close — used by every item, the mobile Cancel, outside-click, + // and the Escape arbiter (reassigned to the registry-aware close below). + let closeDropdown = () => { dropdown.remove(); btn.classList.remove('cookbook-menu-active'); }; const _di = (svg) => `${svg}`; const _serveIco = ''; const _retryIco = ''; @@ -230,8 +235,7 @@ function _rerenderCachedModels() { div.className = 'dropdown-item-compact' + (opt.danger ? ' dropdown-item-danger' : ''); div.innerHTML = _di(opt.icon) + '' + opt.label + ''; div.addEventListener('click', () => { - dropdown.remove(); - btn.classList.remove('cookbook-menu-active'); + closeDropdown(); if (opt.action === 'serve') item.click(); else if (opt.action === 'delete') _deleteCachedModel(repo, item, false, m); else if (opt.action === 'retry') _retryCachedModel(repo, m); @@ -264,10 +268,7 @@ function _rerenderCachedModels() { const cancelDiv = document.createElement('div'); cancelDiv.className = 'dropdown-item-compact dropdown-cancel-mobile'; cancelDiv.innerHTML = _di(_cancelIco) + 'Cancel'; - cancelDiv.addEventListener('click', () => { - dropdown.remove(); - btn.classList.remove('cookbook-menu-active'); - }); + cancelDiv.addEventListener('click', () => { closeDropdown(); }); dropdown.appendChild(cancelDiv); const rect = btn.getBoundingClientRect(); dropdown.style.cssText = `position:fixed;z-index:10001;visibility:hidden;top:0;right:${window.innerWidth-rect.right}px;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:4px;box-shadow:0 8px 24px rgba(0,0,0,0.3);font-size:12px;`; @@ -290,8 +291,7 @@ function _rerenderCachedModels() { dropdown.style.top = top + 'px'; dropdown.style.visibility = ''; } - const close = (ev) => { if (!dropdown.contains(ev.target) && ev.target !== btn) { dropdown.remove(); btn.classList.remove('cookbook-menu-active'); document.removeEventListener('click', close, true); } }; - setTimeout(() => document.addEventListener('click', close, true), 0); + closeDropdown = bindMenuDismiss(dropdown, () => { dropdown.remove(); btn.classList.remove('cookbook-menu-active'); }, (ev) => !dropdown.contains(ev.target) && ev.target !== btn); }); }); @@ -666,10 +666,11 @@ function _rerenderCachedModels() { // reflects the stored presets. Standard Odysseus .dropdown look, positioned // fixed at the toggle and right-aligned to it. function _showSavedConfigMenu(anchor) { - document.querySelectorAll('.cookbook-saved-menu').forEach(d => d.remove()); + document.querySelectorAll('.cookbook-saved-menu').forEach(d => { if (typeof d._dismiss === 'function') d._dismiss(); else d.remove(); }); const modelSlots = _presetsForModel(_loadPresets(), repo); const dropdown = document.createElement('div'); dropdown.className = 'dropdown cookbook-saved-menu'; + let closeMenu = () => { dropdown.remove(); anchor.classList.remove('cookbook-menu-active'); }; const rect = anchor.getBoundingClientRect(); const minW = 190; // Cap width/height to the viewport and start hidden — we clamp the final @@ -710,7 +711,7 @@ function _rerenderCachedModels() { if (e.target === del) return; e.stopPropagation(); // Close the menu FIRST so it always dismisses, even if loading throws. - dropdown.remove(); + closeMenu(); _loadSlotIntoPanel(idx); // Confirm the click landed — loading is silent otherwise, so it was // unclear the settings actually changed. @@ -751,14 +752,7 @@ function _rerenderCachedModels() { dropdown.style.left = `${left}px`; dropdown.style.top = `${top}px`; dropdown.style.visibility = ''; - const close = (ev) => { - if (!dropdown.contains(ev.target) && ev.target !== anchor && !anchor.contains(ev.target)) { - dropdown.remove(); - anchor.classList.remove('cookbook-menu-active'); - document.removeEventListener('click', close, true); - } - }; - setTimeout(() => document.addEventListener('click', close, true), 10); + closeMenu = bindMenuDismiss(dropdown, () => { dropdown.remove(); anchor.classList.remove('cookbook-menu-active'); }, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor && !anchor.contains(ev.target)); } // "Save" segment — save the current config directly. @@ -766,7 +760,7 @@ function _rerenderCachedModels() { if (savedSaveBtn) { savedSaveBtn.addEventListener('click', async (e) => { e.stopPropagation(); - document.querySelectorAll('.cookbook-saved-menu').forEach(d => d.remove()); + document.querySelectorAll('.cookbook-saved-menu').forEach(dismissOrRemove); await _saveCurrentConfig(); }); } @@ -775,9 +769,10 @@ function _rerenderCachedModels() { if (savedArrowBtn) { savedArrowBtn.addEventListener('click', (e) => { e.stopPropagation(); - if (document.querySelector('.cookbook-saved-menu')) { - document.querySelectorAll('.cookbook-saved-menu').forEach(d => d.remove()); - savedArrowBtn.classList.remove('cookbook-menu-active'); + const openSaved = document.querySelector('.cookbook-saved-menu'); + if (openSaved) { + if (typeof openSaved._dismiss === 'function') openSaved._dismiss(); + else { openSaved.remove(); savedArrowBtn.classList.remove('cookbook-menu-active'); } return; } savedArrowBtn.classList.add('cookbook-menu-active'); @@ -822,9 +817,10 @@ function _rerenderCachedModels() { if (_splitArrow) { _splitArrow.addEventListener('click', (ev) => { ev.stopPropagation(); - document.querySelectorAll('.cookbook-gpu-split-menu').forEach(m => m.remove()); + document.querySelectorAll('.cookbook-gpu-split-menu').forEach(m => { if (typeof m._dismiss === 'function') m._dismiss(); else m.remove(); }); const menu = document.createElement('div'); menu.className = 'cookbook-task-dropdown cookbook-gpu-split-menu'; + let closeMenu = () => menu.remove(); const mk = (label, cls, onClick) => { const it = document.createElement('div'); it.className = 'dropdown-item-compact' + (cls ? ' ' + cls : ''); @@ -832,7 +828,7 @@ function _rerenderCachedModels() { it.textContent = label; it.addEventListener('click', (e) => { e.stopPropagation(); - menu.remove(); + closeMenu(); if (onClick) onClick(); }); return it; @@ -859,18 +855,11 @@ function _rerenderCachedModels() { } menu.style.top = top + 'px'; } - const close = (e) => { - if (!menu.contains(e.target) && e.target !== _splitArrow) { - menu.remove(); - document.removeEventListener('click', close); - window.removeEventListener('scroll', _scrollClose, true); - } - }; - const _scrollClose = () => { menu.remove(); document.removeEventListener('click', close); window.removeEventListener('scroll', _scrollClose, true); }; - setTimeout(() => { - document.addEventListener('click', close); - window.addEventListener('scroll', _scrollClose, true); - }, 0); + // Close on outside click or Escape (via the registry); also dismiss + // on scroll since the popup is fixed-positioned to the arrow. + const _scrollClose = () => closeMenu(); + closeMenu = bindMenuDismiss(menu, () => { menu.remove(); window.removeEventListener('scroll', _scrollClose, true); }, (e) => !menu.contains(e.target) && e.target !== _splitArrow); + window.addEventListener('scroll', _scrollClose, true); }); } const _withSpinner = async (btn, fn) => { diff --git a/static/js/documentLibrary.js b/static/js/documentLibrary.js index 977ef83..64c0f9e 100644 --- a/static/js/documentLibrary.js +++ b/static/js/documentLibrary.js @@ -10,6 +10,7 @@ import spinnerModule from './spinner.js'; import markdownModule from './markdown.js'; import { makeWindowDraggable } from './windowDrag.js'; import { langIcon } from './langIcons.js'; +import { registerMenuDismiss, dismissOrRemove } from './escMenuStack.js'; // ── Injected references from documentModule ── let API_BASE = ''; @@ -184,7 +185,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs? function _showLibDropdown(anchor, items, opts) { opts = opts || {}; - document.querySelectorAll('._lib-dd').forEach(d => d.remove()); + document.querySelectorAll('._lib-dd').forEach(dismissOrRemove); const dd = document.createElement('div'); dd.className = 'dropdown session-dropdown-menu _lib-dd'; for (const item of items) { @@ -193,7 +194,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs? const iconKey = item.icon || item.label.toLowerCase(); const iconSvg = _LIB_DD_ICONS[iconKey] || ''; row.innerHTML = (iconSvg ? '' + iconSvg + '' : '') + '' + item.label + ''; - row.addEventListener('click', (e) => { e.stopPropagation(); dd.remove(); item.action(); }); + row.addEventListener('click', (e) => { e.stopPropagation(); teardown(); item.action(); }); dd.appendChild(row); } if (typeof opts.onSelect === 'function') { @@ -202,7 +203,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs? sel.innerHTML = '' + 'Select'; - sel.addEventListener('click', (e) => { e.stopPropagation(); dd.remove(); opts.onSelect(); }); + sel.addEventListener('click', (e) => { e.stopPropagation(); teardown(); opts.onSelect(); }); dd.appendChild(sel); } const cancel = document.createElement('div'); @@ -210,7 +211,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs? cancel.innerHTML = '' + 'Cancel'; - cancel.addEventListener('click', (e) => { e.stopPropagation(); dd.remove(); if (typeof opts.onCancel === 'function') opts.onCancel(); }); + cancel.addEventListener('click', (e) => { e.stopPropagation(); teardown(); if (typeof opts.onCancel === 'function') opts.onCancel(); }); dd.appendChild(cancel); document.body.appendChild(dd); const rect = anchor.getBoundingClientRect(); @@ -225,8 +226,18 @@ let _libraryArchivedView = false; // Documents tab showing archived docs? } if (mr.left < 8) { dd.style.left = '8px'; dd.style.right = 'auto'; } }); - const close = (e) => { if (!dd.contains(e.target)) { dd.remove(); document.removeEventListener('click', close); } }; + // Single idempotent teardown shared by every dismissal path (item click, + // outside click, swipe, and the Escape arbiter via registerMenuDismiss). + let _unreg = () => {}; + const teardown = () => { + _unreg(); _unreg = () => {}; + document.removeEventListener('click', close); + dd.remove(); + }; + const close = (e) => { if (!dd.contains(e.target)) teardown(); }; setTimeout(() => document.addEventListener('click', close), 0); + _unreg = registerMenuDismiss(teardown); + dd._dismiss = teardown; // let bulk removers (reopen sweep) tear down cleanly // Swipe-down-to-dismiss (mobile). Mirrors the bottom-sheet feel — drag the // popup down and release past the threshold to close. Below threshold, @@ -257,8 +268,11 @@ let _libraryArchivedView = false; // Documents tab showing archived docs? dd.style.transition = 'transform 0.15s ease, opacity 0.15s ease'; dd.style.transform = 'translateY(120px)'; dd.style.opacity = '0'; - setTimeout(() => dd.remove(), 160); + // Unregister + drop the outside-click listener now; defer the DOM + // removal so the slide-out animation can play. + _unreg(); _unreg = () => {}; document.removeEventListener('click', close); + setTimeout(() => dd.remove(), 160); } else { dd.style.transition = 'transform 0.18s ease, opacity 0.18s ease'; dd.style.transform = ''; @@ -380,6 +394,10 @@ let _libraryArchivedView = false; // Documents tab showing archived docs? function libraryRenderGrid() { const grid = document.getElementById('doclib-grid'); if (!grid) return; + // An open card menu is mounted on (to escape overflow clipping), so + // clearing the grid would orphan it; dismiss it first so its listener + + // Escape-stack entry go too. + document.querySelectorAll('.doclib-card-dropdown').forEach(dismissOrRemove); grid.innerHTML = ''; // Drop any previous inline load-more — regenerated below alongside the list. if (grid.parentElement) grid.parentElement.querySelectorAll(':scope > .doclib-inline-load-more').forEach(b => b.remove()); @@ -576,8 +594,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs? if (dropdown) { const isOpen = dropdown.style.display !== 'none' && dropdown.parentElement === document.body; if (isOpen) { - dropdown.style.display = 'none'; - menuWrap.appendChild(dropdown); + hideCardDropdown(); } else { // Position fixed on body to escape overflow clipping const rect = menuBtn.getBoundingClientRect(); @@ -593,15 +610,12 @@ let _libraryArchivedView = false; // Documents tab showing archived docs? if (mr.bottom > window.innerHeight - 8) dropdown.style.top = (rect.top - mr.height - 4) + 'px'; if (mr.left < 8) { dropdown.style.left = '8px'; dropdown.style.right = 'auto'; } }); - // Close on outside click - const close = (ev) => { - if (!dropdown.contains(ev.target) && !menuWrap.contains(ev.target)) { - dropdown.style.display = 'none'; - menuWrap.appendChild(dropdown); - document.removeEventListener('click', close, true); - } + // Close on outside click or Escape (the latter via the registry). + _cardDocClick = (ev) => { + if (!dropdown.contains(ev.target) && !menuWrap.contains(ev.target)) hideCardDropdown(); }; - setTimeout(() => document.addEventListener('click', close, true), 0); + setTimeout(() => document.addEventListener('click', _cardDocClick, true), 0); + _cardUnreg = registerMenuDismiss(hideCardDropdown); } } }); @@ -612,6 +626,21 @@ let _libraryArchivedView = false; // Documents tab showing archived docs? dropdown.className = 'doclib-card-dropdown'; dropdown.style.cssText = 'display:none;position:absolute;top:100%;right:0;z-index:1000;min-width:0;width:max-content;padding:4px;background:var(--panel);border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);backdrop-filter:blur(12px);font-size:12px;'; + // Single close path for the card action dropdown, shared by the toggle + // button, the outside-click listener, every menu item, and the Escape + // arbiter (via registerMenuDismiss). Hides the menu, returns it to its + // wrapper, drops the outside-click listener, and unregisters from the + // Escape stack. Idempotent — safe to call from whichever path fires first. + let _cardUnreg = () => {}; + let _cardDocClick = null; + function hideCardDropdown() { + _cardUnreg(); _cardUnreg = () => {}; + if (_cardDocClick) { document.removeEventListener('click', _cardDocClick, true); _cardDocClick = null; } + dropdown.style.display = 'none'; + if (dropdown.parentElement === document.body) menuWrap.appendChild(dropdown); + } + dropdown._dismiss = hideCardDropdown; // bulk removers tear down through this + const _di = (svg) => `${svg}`; const _openIco = ''; @@ -621,7 +650,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs? openItem.style.cssText = 'background:none;border:none;width:100%;'; openItem.innerHTML = _di(_openIco) + 'Open'; if (doc.session_id) { - openItem.addEventListener('click', (e) => { e.stopPropagation(); dropdown.style.display = 'none'; libraryOpenInSession(doc); }); + openItem.addEventListener('click', (e) => { e.stopPropagation(); hideCardDropdown(); libraryOpenInSession(doc); }); } else { openItem.disabled = true; openItem.style.opacity = '0.35'; @@ -636,7 +665,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs? cloneItem.style.cssText = 'background:none;border:none;width:100%;'; cloneItem.innerHTML = _di(_cloneIco) + 'Clone'; cloneItem.title = 'Clone to active session'; - cloneItem.addEventListener('click', (e) => { e.stopPropagation(); dropdown.style.display = 'none'; libraryImportDocument(doc); }); + cloneItem.addEventListener('click', (e) => { e.stopPropagation(); hideCardDropdown(); libraryImportDocument(doc); }); dropdown.appendChild(cloneItem); // Export @@ -647,7 +676,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs? exportItem.innerHTML = _di(_exportIco) + 'Export'; exportItem.addEventListener('click', async (e) => { e.stopPropagation(); - dropdown.style.display = 'none'; + hideCardDropdown(); try { const res = await fetch(`${API_BASE}/api/document/${doc.id}`); if (!res.ok) throw new Error('Failed'); @@ -673,7 +702,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs? archiveItem.title = _libraryArchivedView ? 'Restore to active documents' : 'Archive (hide from the main list)'; archiveItem.addEventListener('click', async (e) => { e.stopPropagation(); - dropdown.style.display = 'none'; + hideCardDropdown(); const toArchived = !_libraryArchivedView; try { const res = await fetch(`${API_BASE}/api/document/${doc.id}/archive?archived=${toArchived}`, { method: 'POST', credentials: 'same-origin' }); @@ -693,7 +722,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs? deleteItem.className = 'dropdown-item-compact dropdown-item-danger'; deleteItem.style.cssText = 'background:none;border:none;width:100%;'; deleteItem.innerHTML = _di(_deleteIco) + 'Delete'; - deleteItem.addEventListener('click', (e) => { e.stopPropagation(); dropdown.style.display = 'none'; libraryDeleteSingle(doc.id, card); }); + deleteItem.addEventListener('click', (e) => { e.stopPropagation(); hideCardDropdown(); libraryDeleteSingle(doc.id, card); }); dropdown.appendChild(deleteItem); menuWrap.appendChild(dropdown); diff --git a/static/js/escMenuStack.js b/static/js/escMenuStack.js new file mode 100644 index 0000000..2bb20c9 --- /dev/null +++ b/static/js/escMenuStack.js @@ -0,0 +1,102 @@ +// static/js/escMenuStack.js +// +// Dismissal registry for transient, ad-hoc overlays — dropdown menus and +// context popups that are built on the fly and appended to , living +// OUTSIDE the .modal system. The global Escape arbiter in ui.js can find +// modals but not these, so each menu registers a dismiss callback here while +// it is open and unregisters when it closes. +// +// The stack is LIFO: dismissTopMenu() closes the most-recently-opened menu +// first, so a dropdown opened on top of a modal closes before the modal does. +// Deliberately DOM-free so it can be unit-tested under plain node (see +// tests/test_esc_menu_stack_js.py). + +const _stack = []; + +/** + * Register a menu's dismiss callback. Returns an unregister function that the + * menu MUST call from its own teardown (outside-click close, item click, etc.) + * so the stack never holds a stale entry. Calling the returned function more + * than once, or after the menu was already dismissed via Escape, is safe. + */ +export function registerMenuDismiss(dismissFn) { + if (typeof dismissFn !== 'function') return () => {}; + const entry = { dismissFn }; + _stack.push(entry); + return () => { + const i = _stack.indexOf(entry); + if (i !== -1) _stack.splice(i, 1); + }; +} + +/** + * Dismiss the most-recently-registered menu, if any. Returns true when a menu + * was dismissed (so the caller can swallow the Escape key), false when nothing + * was open. The entry is popped BEFORE its callback runs, so even if a + * dismissFn forgets to unregister or throws, a single Escape closes exactly + * one menu and the stack never gets stuck. + */ +export function dismissTopMenu() { + const entry = _stack.pop(); + if (!entry) return false; + try { entry.dismissFn(); } catch {} + return true; +} + +/** Test/debug helper: number of currently-registered menus. */ +export function _openMenuCount() { + return _stack.length; +} + +/** + * Tear a transient menu down through its registered dismiss callback if it has + * one (releasing its Escape-stack entry and any listeners), else fall back to a + * plain node removal. Use this anywhere menus are cleared in bulk — scroll / + * swipe / modal-dismiss cleanup, or a "close the previous one" reopen sweep — + * instead of a raw `el.remove()`, which would strand the stack entry. + */ +export function dismissOrRemove(el) { + if (!el) return; + if (typeof el._dismiss === 'function') el._dismiss(); + else el.remove(); +} + +// ── DOM convenience wrapper ────────────────────────────────────────────── +// The registry above is intentionally DOM-free (and unit-tested as such). +// bindMenuDismiss is the thin DOM layer most callers actually want: it wires +// the ubiquitous "overlay appended to , closes on an outside click" +// idiom to BOTH the outside-click listener AND the Escape stack in one call, +// so a menu only has to describe how to tear itself down once. +// +// const close = bindMenuDismiss(popup, () => popup.remove()); +// // outside-click and Escape now both call close(); call it yourself from +// // item handlers too. +// +// `onClose` runs exactly once (idempotent) and owns the actual teardown +// (removing/hiding the node, clearing anchor state, …). `isOutside(ev)` +// defaults to "the click landed outside `el`"; override it when extra anchors +// should count as inside the menu. The returned idempotent close() is also +// stashed on `el._dismiss`, so bulk removers (see dismissOrRemove) can tear the +// menu down through its real teardown rather than orphaning its stack entry. +export function bindMenuDismiss(el, onClose, isOutside) { + let done = false; + let unreg = () => {}; + const onDocClick = (ev) => { + const outside = typeof isOutside === 'function' ? isOutside(ev) : !el.contains(ev.target); + if (outside) close(); + }; + function close() { + if (done) return; + done = true; + unreg(); unreg = () => {}; + document.removeEventListener('click', onDocClick, true); + try { if (typeof onClose === 'function') onClose(); } catch {} + } + // Defer attaching the outside-click listener so the opening click doesn't + // immediately close the menu. Skip the attach if close() already ran in the + // same tick (e.g. an instant Escape) so we never leave a dangling listener. + setTimeout(() => { if (!done) document.addEventListener('click', onDocClick, true); }, 0); + unreg = registerMenuDismiss(close); + el._dismiss = close; + return close; +} diff --git a/static/js/modalManager.js b/static/js/modalManager.js index c28cfba..fb5331e 100644 --- a/static/js/modalManager.js +++ b/static/js/modalManager.js @@ -27,6 +27,7 @@ import { previewZoneAt, clearPreview, snapModalToZone } from './tileManager.js'; import { suspendDock, resumeDock, clearRightDock, applyEdgeDock } from './modalSnap.js'; +import { dismissOrRemove } from './escMenuStack.js'; const _state = new Map(); // id -> { restoreFn, closeFn, railBtnId, isMinimized, restoreMinHeight } @@ -1463,7 +1464,7 @@ window.addEventListener('modal-dismissed', (e) => { if (id === 'cookbook-modal') { document.querySelectorAll( '.cookbook-task-dropdown, .cookbook-gpu-split-menu, .hwfit-cached-dropdown, .cookbook-saved-menu, .cookbook-dep-menu' - ).forEach(d => d.remove()); + ).forEach(dismissOrRemove); } }); diff --git a/static/js/ui.js b/static/js/ui.js index a92e285..f535578 100644 --- a/static/js/ui.js +++ b/static/js/ui.js @@ -7,6 +7,7 @@ import themeModule from './theme.js'; import * as Modals from './modalManager.js'; import spinnerModule from './spinner.js'; +import { registerMenuDismiss, dismissTopMenu, dismissOrRemove } from './escMenuStack.js'; let toastEl = null; let autoScrollEnabled = true; @@ -769,7 +770,7 @@ function _initScrollDismiss() { if (chatHistory) { chatHistory.addEventListener('scroll', () => { chatHistory.querySelectorAll('.dropdown.show').forEach(d => d.classList.remove('show')); - document.querySelectorAll('.ctx-popup').forEach(p => p.remove()); + document.querySelectorAll('.ctx-popup').forEach(dismissOrRemove); }, { passive: true }); } else { // Retry once if element doesn't exist yet @@ -822,7 +823,8 @@ const uiModule = { el, esc, isTouchInsideModal, - emptyStateIcon + emptyStateIcon, + registerMenuDismiss }; export default uiModule; @@ -883,7 +885,9 @@ if ('ontouchstart' in window) { '.email-card-dropdown, .hwfit-cached-dropdown, .cookbook-saved-menu, .cookbook-dep-menu' ).forEach(d => { if (d._anchor) d._anchor.classList.remove('cookbook-menu-active', 'reader-more-active'); - d.remove(); + // Registered menus tear down through their own dismiss (releasing the + // Escape-stack entry); unregistered ones (email/dep) just get removed. + dismissOrRemove(d); }); } @@ -1200,6 +1204,15 @@ if (!window._odyEscExpandGuard) { e.stopImmediatePropagation(); e.preventDefault(); return; } + // Transient ad-hoc menus (dropdowns / context popups) live outside the + // .modal system and register a dismiss callback in escMenuStack. Close the + // most-recently-opened one first — so a menu opened over a modal dismisses + // before the modal — and do it BEFORE the text-input guard below, since a + // menu may own the focused input (e.g. a search dropdown). + if (dismissTopMenu()) { + e.stopImmediatePropagation(); e.preventDefault(); + return; + } const t = e.target; if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return; const expanded = document.querySelector('.doclib-card-expanded'); diff --git a/tests/test_esc_menu_stack_js.py b/tests/test_esc_menu_stack_js.py new file mode 100644 index 0000000..92ab661 --- /dev/null +++ b/tests/test_esc_menu_stack_js.py @@ -0,0 +1,116 @@ +"""Pin the DOM-free Escape-dismissal registry in static/js/escMenuStack.js. + +Driven through `node --input-type=module` so we exercise the real JS without a +full Vitest/Jest setup (same spirit as test_reply_recipients_js.py). Skips when +`node` is not installed rather than failing. + +The module source is inlined into the eval'd module body (rather than imported +by path) so the test runs identically on Windows and POSIX — the repo has no +`"type": "module"` in package.json, so a path import of a `.js` file is treated +as CommonJS by node and rejects the ES `export`s. escMenuStack.js has no +imports of its own, so inlining is exact. + +Background: ad-hoc dropdowns/popups (document-library card menus, chat context +popups, cookbook serve menus, calendar event menus, compare pane menus) live +outside the .modal system, so the global Escape arbiter in ui.js couldn't see +them. They register a dismiss callback here while open; the arbiter calls +dismissTopMenu() to close the most-recently-opened one. These tests lock in the +LIFO contract and the "exactly one menu per Escape, never get stuck" guarantees +the arbiter relies on. +""" +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +_REPO = Path(__file__).resolve().parent.parent +_HELPER = _REPO / "static" / "js" / "escMenuStack.js" +_HAS_NODE = shutil.which("node") is not None +_SRC = _HELPER.read_text(encoding="utf-8") if _HELPER.exists() else "" + + +def _run(body: str) -> str: + """Run `body` as a module with the registry's functions already in scope.""" + js = _SRC + "\n" + body + proc = subprocess.run( + ["node", "--input-type=module"], + input=js, capture_output=True, text=True, encoding="utf-8", + cwd=str(_REPO), timeout=30, + ) + assert proc.returncode == 0, proc.stderr + return proc.stdout.strip() + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_empty_stack_dismiss_is_noop(): + # Nothing open: returns false so the arbiter can fall through to modals. + body = "console.log(JSON.stringify([dismissTopMenu(), _openMenuCount()]));" + assert json.loads(_run(body)) == [False, 0] + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_dismiss_is_lifo_and_closes_exactly_one(): + body = """ + const order = []; + registerMenuDismiss(() => order.push('A')); + registerMenuDismiss(() => order.push('B')); + const r1 = dismissTopMenu(); // closes B (most recent) + const r2 = dismissTopMenu(); // closes A + const r3 = dismissTopMenu(); // nothing left + console.log(JSON.stringify({ order, r1, r2, r3, left: _openMenuCount() })); + """ + out = json.loads(_run(body)) + assert out["order"] == ["B", "A"] # LIFO + assert [out["r1"], out["r2"], out["r3"]] == [True, True, False] + assert out["left"] == 0 + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_unregister_removes_entry_without_firing(): + body = """ + let fired = false; + const unreg = registerMenuDismiss(() => { fired = true; }); + unreg(); // menu closed itself via outside-click + const r = dismissTopMenu(); // Escape should now find nothing + console.log(JSON.stringify({ fired, r, left: _openMenuCount() })); + """ + # Unregistering must not invoke the callback and must leave the stack empty. + assert json.loads(_run(body)) == {"fired": False, "r": False, "left": 0} + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_unregister_targets_correct_entry_when_interleaved(): + body = """ + const order = []; + const unregA = registerMenuDismiss(() => order.push('A')); + registerMenuDismiss(() => order.push('B')); + unregA(); // remove the older entry, keep B + dismissTopMenu(); // should fire B, not A + console.log(JSON.stringify({ order, left: _openMenuCount() })); + """ + out = json.loads(_run(body)) + assert out["order"] == ["B"] + assert out["left"] == 0 + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_throwing_dismiss_still_pops_and_reports_handled(): + body = """ + registerMenuDismiss(() => { throw new Error('boom'); }); + const r = dismissTopMenu(); // must swallow the error... + console.log(JSON.stringify({ r, left: _openMenuCount() })); + """ + # A misbehaving menu must not wedge the stack or crash the arbiter. + assert json.loads(_run(body)) == {"r": True, "left": 0} + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_non_function_registration_is_ignored(): + body = """ + const unreg = registerMenuDismiss(null); + console.log(JSON.stringify({ left: _openMenuCount(), unregType: typeof unreg })); + """ + # Bad input must not enter the stack, and must still return a callable. + assert json.loads(_run(body)) == {"left": 0, "unregType": "function"}