fix(modal): keep body-portaled dropdowns above their tool modal at any stack depth (#4720) (#4724)

* fix(memory): keep the Brain memory item menu above the modal at any stack depth

The memory item "⋮" dropdown is portaled to <body> 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.
This commit is contained in:
Max Hsu
2026-06-23 16:24:31 +08:00
committed by GitHub
parent 7e5db9a3c6
commit fef08ed114
11 changed files with 137 additions and 16 deletions
+2 -1
View File
@@ -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');
+2 -1
View File
@@ -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 = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/></svg>';
const it = document.createElement('div');
it.className = 'dropdown-item-compact';
+3 -2
View File
@@ -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');
+3 -2
View File
@@ -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';
+5 -4
View File
@@ -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) => `<span class="dropdown-icon">${svg}</span>`;
const _unreadIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>';
@@ -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) => `<span class="dropdown-icon">${svg}</span>`;
const _replyIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>';
@@ -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 = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13"/><path d="m22 2-7 20-4-9-9-4 20-7z"/></svg>';
const _unreadIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>';
const _doneIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
@@ -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',
+3 -1
View File
@@ -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) => `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`;
@@ -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();
+2 -1
View File
@@ -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 = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 21s-6.7-4.35-9.33-8.04C.9 10.3 1.4 6.9 4.1 5.6c1.9-.9 4 .03 5 1.7 1-1.67 3.1-2.6 5-1.7 2.7 1.3 3.2 4.7 1.43 7.36C18.7 16.65 12 21 12 21z"/></svg>';
const _tagIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.59 13.41 13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>';
const _dlIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
+8 -1
View File
@@ -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 <body>, 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
+3 -3
View File
@@ -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();
+17
View File
@@ -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;
}
+89
View File
@@ -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}