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.
2026-06-23 16:24:31 +08:00
|
|
|
"""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
|
2026-06-24 22:29:36 +02:00
|
|
|
import re
|
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.
2026-06-23 16:24:31 +08:00
|
|
|
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}
|
2026-06-24 22:29:36 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# 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}"
|