From fef08ed11471782708adf2915fabc8d27c9ea6b4 Mon Sep 17 00:00:00 2001 From: Max Hsu Date: Tue, 23 Jun 2026 16:24:31 +0800 Subject: [PATCH] fix(modal): keep body-portaled dropdowns above their tool modal at any stack depth (#4720) (#4724) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(memory): keep the Brain memory item menu above the modal at any stack depth The memory item "⋮" dropdown is portaled to with a hardcoded z-index of 10001. Tool modals, however, get a monotonically increasing z-index from modalManager's bring-to-front counter (_modalTopZ), which climbs unbounded as modals are opened/restored over a session. Once that counter passes 10001, the Brain modal stacks above the body-portaled dropdown, so the menu renders behind the panel — visible only where it spills past the modal's edge (#4720). Derive the dropdown's z-index from the owning modal's current z-index (+1), keeping 10001 as a floor for the common low-counter case, so the menu always sits just above its modal however high the counter has climbed. Verified with document.elementFromPoint at the dropdown's location: with a high modal z-index the old build returns the modal at every sampled point (menu behind); the fixed build returns the dropdown (menu on top). The default low-counter case is unchanged (z stays 10001). * refactor(modal): route body-portaled dropdowns through a shared topPortalZ() helper The hardcoded z-index:10001 the Brain memory menu used (#4720) is the same literal shared by ~16 body-portaled dropdowns across calendar, cookbook, cookbookServe, documentLibrary, emailLibrary, gallery, notes, emojiPicker and memory — each renders behind its owning tool modal once modalManager's bring-to-front counter climbs past the literal over a long session. Promote the per-dropdown fix into a single topPortalZ() helper in toolWindowZOrder.js — the existing source of truth for tool-window z, already imported by modalManager's _bringToFront and notes.js — returning max(topToolWindowZ(), dock-chip floor) + 1, so a portaled dropdown always sits just above the live tool-window stack however high the counter has climbed. Route all 16 sites through it. The slashCommands tour tooltips and the cookbookServe VRAM dialog are intentionally left out (neither is a modal-owned portaled dropdown). Add tests/test_portal_dropdown_z_js.py covering the helper, including the #4720 scenario (modal counter at 99999 -> dropdown at 100000). Existing test_notes_z_order_js.py stays green. --- static/js/calendar.js | 3 +- static/js/cookbook.js | 3 +- static/js/cookbookServe.js | 5 +- static/js/documentLibrary.js | 5 +- static/js/emailLibrary.js | 9 +-- static/js/emojiPicker.js | 4 +- static/js/gallery.js | 3 +- static/js/memory.js | 9 ++- static/js/notes.js | 6 +- static/js/toolWindowZOrder.js | 17 ++++++ tests/test_portal_dropdown_z_js.py | 89 ++++++++++++++++++++++++++++++ 11 files changed, 137 insertions(+), 16 deletions(-) create mode 100644 tests/test_portal_dropdown_z_js.py diff --git a/static/js/calendar.js b/static/js/calendar.js index 26a235523..dca510d24 100644 --- a/static/js/calendar.js +++ b/static/js/calendar.js @@ -5,6 +5,7 @@ import uiModule from './ui.js'; import spinnerModule from './spinner.js'; import * as Modals from './modalManager.js'; +import { topPortalZ } from './toolWindowZOrder.js'; import { makeWindowDraggable } from './windowDrag.js'; import { attachColorPicker } from './colorPicker.js'; import { bindMenuDismiss } from './escMenuStack.js'; @@ -470,7 +471,7 @@ function _showEventMoreMenu(ev, anchor) { 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;`; + dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};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;`; const _item = (icon, label, onClick, danger) => { const it = document.createElement('div'); diff --git a/static/js/cookbook.js b/static/js/cookbook.js index 967557184..fca21b57e 100644 --- a/static/js/cookbook.js +++ b/static/js/cookbook.js @@ -34,6 +34,7 @@ import { } from './cookbookServe.js'; import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js'; +import { topPortalZ } from './toolWindowZOrder.js'; const STORAGE_KEY = 'cookbook-presets'; const LAST_STATE_KEY = 'cookbook-last-state'; @@ -1529,7 +1530,7 @@ async function _fetchDependencies() { const minW = 150; let left = Math.min(rect.right - minW, window.innerWidth - minW - 8); left = Math.max(8, left); - dropdown.style.cssText = `position:fixed;display:block;z-index:10001;top:${rect.bottom + 6}px;left:${left}px;right:auto;min-width:${minW}px;max-width:calc(100vw - 16px);background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`; + dropdown.style.cssText = `position:fixed;display:block;z-index:${topPortalZ()};top:${rect.bottom + 6}px;left:${left}px;right:auto;min-width:${minW}px;max-width:calc(100vw - 16px);background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`; const upIco = ''; const it = document.createElement('div'); it.className = 'dropdown-item-compact'; diff --git a/static/js/cookbookServe.js b/static/js/cookbookServe.js index 06a990b82..b905d8796 100644 --- a/static/js/cookbookServe.js +++ b/static/js/cookbookServe.js @@ -11,6 +11,7 @@ import { modelColor } from './chatRenderer.js'; import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js'; import { openCookbookDependencies } from './cookbook-diagnosis.js'; import { _hwfitCache } from './cookbook-hwfit.js'; +import { topPortalZ } from './toolWindowZOrder.js'; // Shared state/functions injected by init() let _envState; @@ -1019,7 +1020,7 @@ function _rerenderCachedModels() { 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;`; + dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};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;`; document.body.appendChild(dropdown); // Clamp into the VISIBLE area (visualViewport, not innerHeight — they differ // on mobile under the dynamic toolbar). Flip above the button if there's no @@ -2166,7 +2167,7 @@ function _rerenderCachedModels() { // Cap width/height to the viewport and start hidden — we clamp the final // position after mount (below) using the menu's real measured size, so it // can't run off-screen on a narrow mobile viewport. - dropdown.style.cssText = `position:fixed;display:block;visibility:hidden;z-index:10001;top:0;left:0;right:auto;min-width:${minW}px;max-width:calc(100vw - 16px);max-height:calc(100vh - 24px);overflow-y:auto;box-sizing:border-box;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`; + dropdown.style.cssText = `position:fixed;display:block;visibility:hidden;z-index:${topPortalZ()};top:0;left:0;right:auto;min-width:${minW}px;max-width:calc(100vw - 16px);max-height:calc(100vh - 24px);overflow-y:auto;box-sizing:border-box;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`; if (!modelSlots.length) { const empty = document.createElement('div'); diff --git a/static/js/documentLibrary.js b/static/js/documentLibrary.js index afe0ffdad..c9f163c49 100644 --- a/static/js/documentLibrary.js +++ b/static/js/documentLibrary.js @@ -4,6 +4,7 @@ * Extracted from document.js to reduce file size. */ +import { topPortalZ } from './toolWindowZOrder.js'; import uiModule from './ui.js'; import sessionModule from './sessions.js'; import spinnerModule from './spinner.js'; @@ -227,7 +228,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs? dd.style.right = (window.innerWidth - rect.right) + 'px'; dd.style.top = (rect.bottom + 2) + 'px'; dd.style.display = 'block'; - dd.style.zIndex = '100000'; + dd.style.zIndex = String(topPortalZ()); requestAnimationFrame(() => { const mr = dd.getBoundingClientRect(); if (mr.bottom > window.innerHeight - 8) { @@ -629,7 +630,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs? const rect = menuBtn.getBoundingClientRect(); document.body.appendChild(dropdown); dropdown.dataset.owner = doc.id; - dropdown.style.cssText = 'position:fixed;z-index:10000;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;display:block;'; + dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};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;display:block;`; dropdown.style.top = (rect.bottom + 4) + 'px'; dropdown.style.left = 'auto'; dropdown.style.right = (window.innerWidth - rect.right) + 'px'; diff --git a/static/js/emailLibrary.js b/static/js/emailLibrary.js index 516bc932a..64e9d3a30 100644 --- a/static/js/emailLibrary.js +++ b/static/js/emailLibrary.js @@ -8,6 +8,7 @@ import { styledConfirm, showToast, emptyStateIcon } from './ui.js'; import { folderDisplayName, sortedFolders } from './emailInbox.js'; import settingsModule from './settings.js'; import * as Modals from './modalManager.js'; +import { topPortalZ } from './toolWindowZOrder.js'; import { makeWindowDraggable } from './windowDrag.js'; import { _esc, _escLinkify, _extractName, _parseTurnMeta, @@ -5512,7 +5513,7 @@ function _showReaderMoreMenu(em, card, reader, anchor) { dropdown._anchor = anchor; anchor.classList.add('reader-more-active'); 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;right:${window.innerWidth - rect.right}px;`; + dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};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;right:${window.innerWidth - rect.right}px;`; const _icon = (svg) => `${svg}`; const _unreadIcon = ''; @@ -5749,7 +5750,7 @@ function _showCardMenu(em, anchor) { const dropdown = document.createElement('div'); dropdown.className = 'email-card-dropdown'; const rect = anchor.getBoundingClientRect(); - dropdown.style.cssText = `position:fixed;z-index:10001;min-width:140px;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;right:${window.innerWidth - rect.right}px;`; + dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};min-width:140px;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;right:${window.innerWidth - rect.right}px;`; const _icon = (svg) => `${svg}`; const _replyIcon = ''; @@ -5940,7 +5941,7 @@ function _showBulkActionsMenu(anchor) { const dropdown = document.createElement('div'); dropdown.className = 'email-card-dropdown email-bulk-menu'; const rect = anchor.getBoundingClientRect(); - dropdown.style.cssText = `position:fixed;z-index:10001;min-width:160px;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:${rect.left}px;`; + dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};min-width:160px;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:${rect.left}px;`; const _readIco = ''; const _unreadIco = ''; const _doneIco = ''; @@ -6221,7 +6222,7 @@ function _showAiReplyChoice(btn, em, data) { `max-height:${window.innerHeight - 16}px`, 'overflow:auto', 'box-sizing:border-box', - 'z-index:10060', + `z-index:${topPortalZ()}`, 'display:flex', 'gap:6px', 'padding:6px', diff --git a/static/js/emojiPicker.js b/static/js/emojiPicker.js index d81d8521a..d932d2194 100644 --- a/static/js/emojiPicker.js +++ b/static/js/emojiPicker.js @@ -8,6 +8,8 @@ * faces (😂, 👍, 😎) have no text form and are intentionally excluded. */ +import { topPortalZ } from './toolWindowZOrder.js'; + // Each entry: [char, label, svgPath OR svg] // SVG icons matching Lucide style (24x24 viewBox, 2 stroke) const I = (path) => `${path}`; @@ -158,7 +160,7 @@ function togglePicker(anchor, target) { _pickerEl.style.position = 'fixed'; _pickerEl.style.top = (rect.bottom + 4) + 'px'; _pickerEl.style.left = rect.left + 'px'; - _pickerEl.style.zIndex = '10000'; + _pickerEl.style.zIndex = String(topPortalZ()); requestAnimationFrame(() => { const pr = _pickerEl.getBoundingClientRect(); diff --git a/static/js/gallery.js b/static/js/gallery.js index 40a66a7ee..c5526d5c8 100644 --- a/static/js/gallery.js +++ b/static/js/gallery.js @@ -7,6 +7,7 @@ import { openEditor, closeEditor, isEditorOpen } from './galleryEditor.js'; import spinnerModule from './spinner.js'; import { makeWindowDraggable } from './windowDrag.js'; import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js'; +import { topPortalZ } from './toolWindowZOrder.js'; const API_BASE = window.location.origin; let _open = false; @@ -2524,7 +2525,7 @@ export function openGallery() { const left = Math.min(rect.left, window.innerWidth - 200); // Inline the standard dropdown look so it renders correctly even where the // `.dropdown` rule is scoped out (e.g. hover-only media queries on mobile). - dropdown.style.cssText = `position:fixed;display:block;z-index:10001;top:${rect.bottom + 6}px;left:${Math.max(8, left)}px;right:auto;min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`; + dropdown.style.cssText = `position:fixed;display:block;z-index:${topPortalZ()};top:${rect.bottom + 6}px;left:${Math.max(8, left)}px;right:auto;min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`; const _favIco = ''; const _tagIco = ''; const _dlIco = ''; diff --git a/static/js/memory.js b/static/js/memory.js index b5de2bfe6..a15e3b513 100644 --- a/static/js/memory.js +++ b/static/js/memory.js @@ -6,6 +6,7 @@ import sessionModule from './sessions.js'; import spinnerModule from './spinner.js'; import { makeWindowDraggable } from './windowDrag.js'; import { snapModalToZone } from './tileManager.js'; +import { topPortalZ } from './toolWindowZOrder.js'; var escapeHtml = uiModule.esc; @@ -865,7 +866,13 @@ export function renderMemoryList() { dropdown.style.top = rect.bottom + 2 + 'px'; dropdown.style.right = (window.innerWidth - rect.right) + 'px'; dropdown.style.left = 'auto'; - dropdown.style.zIndex = '10001'; + // Portaled to , so it must outrank the Brain modal it belongs to. + // Tool modals get a monotonically increasing z-index from modalManager's + // bring-to-front counter, which climbs unbounded over a long session — + // once it passed the old hardcoded 10001 the menu rendered behind the + // panel (#4720). topPortalZ() derives the value from the live tool-window + // stack so the menu always sits just above, however high it has climbed. + dropdown.style.zIndex = String(topPortalZ()); dropdown.style.display = 'block'; document.body.appendChild(dropdown); // Keep on-screen (mobile): flip above the button if it overflows the diff --git a/static/js/notes.js b/static/js/notes.js index f19e350c4..cf9886439 100644 --- a/static/js/notes.js +++ b/static/js/notes.js @@ -10,7 +10,7 @@ import { attachColorPicker } from './colorPicker.js'; import { makeWindowDraggable } from './windowDrag.js'; import { snapModalToZone } from './tileManager.js'; import { applyEdgeDock, clearDockSide } from './modalSnap.js'; -import { topToolWindowZ } from './toolWindowZOrder.js'; +import { topToolWindowZ, topPortalZ } from './toolWindowZOrder.js'; import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js'; const API_BASE = window.location.origin; @@ -4335,7 +4335,7 @@ function _openNoteCornerMenu(btn) { const mh = menu.offsetHeight || 96; const below = window.innerHeight - r.bottom; const top = (below < mh + 8 && r.top > mh + 8) ? (r.top - mh - 4) : (r.bottom + 4); - menu.style.cssText += `position:fixed;z-index:11000;top:${Math.round(top)}px;left:${Math.round(left)}px;`; + menu.style.cssText += `position:fixed;z-index:${topPortalZ()};top:${Math.round(top)}px;left:${Math.round(left)}px;`; const close = bindMenuDismiss(menu, () => { menu.remove(); }); menu.querySelector('[data-act="copy"]').addEventListener('click', () => { close(); _copyNote(id, btn); }); menu.querySelector('[data-act="agent"]').addEventListener('click', () => { close(); _agentSolveNote(id); }); @@ -4349,7 +4349,7 @@ function _positionNoteMenu(menu, btn, width = 196) { const mh = menu.offsetHeight || 112; const below = window.innerHeight - r.bottom; const top = (below < mh + 8 && r.top > mh + 8) ? (r.top - mh - 4) : (r.bottom + 4); - menu.style.cssText += `position:fixed;z-index:11000;top:${Math.round(top)}px;left:${Math.round(left)}px;min-width:${width}px;`; + menu.style.cssText += `position:fixed;z-index:${topPortalZ()};top:${Math.round(top)}px;left:${Math.round(left)}px;min-width:${width}px;`; const close = (ev) => { if (ev && menu.contains(ev.target)) return; menu.remove(); diff --git a/static/js/toolWindowZOrder.js b/static/js/toolWindowZOrder.js index fa8241044..300bd28f8 100644 --- a/static/js/toolWindowZOrder.js +++ b/static/js/toolWindowZOrder.js @@ -27,3 +27,20 @@ export function nextToolWindowZ(options = {}) { if (Number.isFinite(currentZ) && currentZ > top) return currentZ; return top + 1; } + +// Dock chips pinned by the minimized-dock drag interactions reach z 10030 +// (free-drag) / 10020 (mobile rest) — see modalManager.js. A body-portaled +// dropdown has to clear those too, not just the open tool-window stack, so this +// floor keeps it above a chip even when no modal is currently raised. +const DOCK_OVERLAY_FLOOR = 10030; + +// The z a body-portaled dropdown/menu needs so it always sits just above every +// open tool window (and the dock chips) right now. Tool modals get a +// monotonically increasing z from the bring-to-front counter (modalManager), +// which climbs unbounded over a long session — so the hardcoded `z-index: 10001` +// these dropdowns historically used eventually rendered them BEHIND their own +// modal (#4720). Derive the value from the live stack instead, sharing the same +// single source of truth as nextToolWindowZ(). +export function topPortalZ(options = {}) { + return Math.max(topToolWindowZ(options), DOCK_OVERLAY_FLOOR) + 1; +} diff --git a/tests/test_portal_dropdown_z_js.py b/tests/test_portal_dropdown_z_js.py new file mode 100644 index 000000000..6f34cfc1a --- /dev/null +++ b/tests/test_portal_dropdown_z_js.py @@ -0,0 +1,89 @@ +"""Node-driven regression coverage for body-portaled dropdown z-order. + +Tool-modal z climbs unbounded via modalManager's bring-to-front counter, so the +old hardcoded `z-index: 10001` shared by ~16 body-portaled dropdowns eventually +rendered them BEHIND their own modal in a long session (#4720). topPortalZ() +replaces every one of those literals with a value derived from the live +tool-window stack. These tests pin that it always clears both the modal stack +and the dock-chip floor, without importing the browser-heavy UI modules. +""" + +import json +import shutil +import subprocess +import textwrap +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +HELPER = ROOT / "static" / "js" / "toolWindowZOrder.js" +pytestmark = pytest.mark.skipif(not shutil.which("node"), reason="node binary not on PATH") + + +def _node_eval(source: str): + proc = subprocess.run( + ["node", "--input-type=module"], + input=source, + cwd=ROOT, + capture_output=True, + text=True, + timeout=30, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout.strip()) + + +def test_portal_z_clears_dock_chip_floor_when_no_modal_is_open(): + # No tool window raised → topToolWindowZ floors at 250, but a portaled + # dropdown must still clear the dock chips pinned up to 10030, so it lands + # just above that floor. + values = _node_eval( + textwrap.dedent( + f""" + import {{ topPortalZ }} from '{HELPER.as_uri()}'; + const root = {{ querySelectorAll() {{ return []; }} }}; + console.log(JSON.stringify({{ z: topPortalZ({{ root, getStyle: () => ({{}}) }}) }})); + """ + ) + ) + + assert values == {"z": 10031} + + +def test_portal_z_sits_above_a_modal_whose_counter_has_climbed_past_10001(): + # The #4720 scenario: a long session bumped the owning modal's bring-to-front + # z to 99999. A hardcoded 10001 dropdown rendered BEHIND it; topPortalZ must + # land one above the live modal z. + values = _node_eval( + textwrap.dedent( + f""" + import {{ topPortalZ }} from '{HELPER.as_uri()}'; + const cls = (...names) => ({{ contains: (name) => names.includes(name) }}); + const modal = {{ id: 'memory-modal', classList: cls(), style: {{ zIndex: '99999' }} }}; + const root = {{ querySelectorAll() {{ return [modal]; }} }}; + console.log(JSON.stringify({{ z: topPortalZ({{ root, getStyle: (el) => el.style }}) }})); + """ + ) + ) + + assert values == {"z": 100000} + + +def test_portal_z_uses_chip_floor_when_the_open_modal_sits_below_it(): + # A modal raised to 5000 is still below the dock-chip floor, so the floor + # (10030) wins and the dropdown lands at 10031 — never below a pinned chip. + values = _node_eval( + textwrap.dedent( + f""" + import {{ topPortalZ }} from '{HELPER.as_uri()}'; + const cls = (...names) => ({{ contains: (name) => names.includes(name) }}); + const modal = {{ id: 'cookbook-modal', classList: cls(), style: {{ zIndex: '5000' }} }}; + const root = {{ querySelectorAll() {{ return [modal]; }} }}; + console.log(JSON.stringify({{ z: topPortalZ({{ root, getStyle: (el) => el.style }}) }})); + """ + ) + ) + + assert values == {"z": 10031}