diff --git a/static/js/skills.js b/static/js/skills.js index d60c933a2..84974d446 100644 --- a/static/js/skills.js +++ b/static/js/skills.js @@ -8,6 +8,7 @@ import uiModule from './ui.js'; import * as spinnerModule from './spinner.js'; import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js'; +import { topPortalZ } from './toolWindowZOrder.js'; const API = window.location.origin; let skills = []; @@ -437,6 +438,10 @@ function _openSkillMenu(btn, card, sk, name, isPublished) { menu.appendChild(cancelItem); document.body.appendChild(menu); + // Override the CSS z-index (100002) with a value derived from the live + // tool-window stack so the kebab menu stays above its modal even after the + // bring-to-front counter climbs past the static value (#4720). + menu.style.zIndex = String(topPortalZ()); const r = btn.getBoundingClientRect(); menu.style.top = (r.bottom + 4) + 'px'; menu.style.right = Math.max(6, window.innerWidth - r.right) + 'px'; diff --git a/static/js/tasks.js b/static/js/tasks.js index e44d13034..ef9015af7 100644 --- a/static/js/tasks.js +++ b/static/js/tasks.js @@ -6,6 +6,7 @@ import uiModule from './ui.js'; import markdownModule from './markdown.js'; import * as spinnerModule from './spinner.js'; import { makeWindowDraggable } from './windowDrag.js'; +import { topPortalZ } from './toolWindowZOrder.js'; import { sortModelIds } from './modelSort.js'; import { ordinalSuffix } from './util/ordinal.js'; import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js'; @@ -903,7 +904,7 @@ function _showTaskDropdown(anchor, items) { document.querySelectorAll('.task-dropdown').forEach(dismissOrRemove); const dd = document.createElement('div'); dd.className = 'task-dropdown'; - dd.style.cssText = 'position:fixed;z-index:100000;background:var(--panel);border:1px solid var(--border);border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.3);padding:4px;min-width:120px;'; + dd.style.cssText = 'position:fixed;background:var(--panel);border:1px solid var(--border);border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.3);padding:4px;min-width:120px;'; items.forEach(item => { const btn = document.createElement('button'); btn.style.cssText = 'display:flex;align-items:center;gap:8px;width:100%;text-align:left;padding:6px 10px;border:none;background:none;color:var(--fg);font-size:11px;font-family:inherit;cursor:pointer;border-radius:4px;transition:background 0.1s;'; @@ -919,6 +920,10 @@ function _showTaskDropdown(anchor, items) { dd.appendChild(btn); }); document.body.appendChild(dd); + // Sit above the currently-raised tool modal at any stack depth (#4720): the + // modal bring-to-front counter climbs unbounded, so a hardcoded z eventually + // loses. topPortalZ() derives the value from the live tool-window stack. + dd.style.zIndex = String(topPortalZ()); const rect = anchor.getBoundingClientRect(); let top = rect.bottom + 4; let left = rect.right - dd.offsetWidth; diff --git a/static/style.css b/static/style.css index c7bdc11a8..aa0b0f836 100644 --- a/static/style.css +++ b/static/style.css @@ -16960,7 +16960,8 @@ body:not(.email-doc-split-active) #email-lib-modal.email-lib-fullscreen:not(.mod /* Kebab dropdown */ .skill-kebab-menu { position: fixed; - z-index: 100002; + /* z-index is set inline via topPortalZ() at open time (#4720); a static + value here loses once the modal bring-to-front counter climbs past it. */ min-width: 150px; padding: 4px; background: var(--panel, var(--bg)); diff --git a/tests/test_portal_dropdown_z_js.py b/tests/test_portal_dropdown_z_js.py index 6f34cfc1a..71248ee7c 100644 --- a/tests/test_portal_dropdown_z_js.py +++ b/tests/test_portal_dropdown_z_js.py @@ -9,6 +9,7 @@ and the dock-chip floor, without importing the browser-heavy UI modules. """ import json +import re import shutil import subprocess import textwrap @@ -87,3 +88,22 @@ def test_portal_z_uses_chip_floor_when_the_open_modal_sits_below_it(): ) assert values == {"z": 10031} + + +# tasks.js and skills.js were not in #4724's batch; #4767 routes their portaled +# dropdowns through the same helper. Pin that they use topPortalZ() and carry no +# hardcoded portal z-index, so they cannot regress to the #4720 bug. +@pytest.mark.parametrize("rel", ["static/js/tasks.js", "static/js/skills.js"]) +def test_late_routed_dropdowns_use_top_portal_z(rel): + src = (ROOT / rel).read_text() + assert "topPortalZ" in src, f"{rel} must import/use topPortalZ()" + assert "topPortalZ()" in src, f"{rel} must call topPortalZ() for its dropdown z" + + +@pytest.mark.parametrize("rel", ["static/js/tasks.js", "static/js/skills.js", "static/style.css"]) +def test_no_hardcoded_portal_z_literals_remain(rel): + src = (ROOT / rel).read_text() + # Match the exact 100000/100002 these dropdowns used; the trailing-digit + # guard avoids false-matching an unrelated 1000000 elsewhere. + hits = re.findall(r"z-index:\s*10000[02](?!\d)", src) + assert not hits, f"{rel} still has hardcoded portal z: {hits}"