fix(dashboard): portal all floating modals + lift z-band so they stack above page overlays and tap-to-front works cross-type

Root causes of modals being overlapped + tap-to-front not working across types:
- Only FloatingWindow was portaled; the dock pop-out, terminal, and New Task rendered inline in their own stacking contexts, so the shared z-counter could not order them across types. Portal all three to document.body so all four floating modals share the one root stacking context.
- The floating band started at 4000, below page overlays/popovers at 10000-10001 (log viewer, workflow editor, selection popover, fullscreen overlay) which painted over the modals. Raise the shared band base to 10100. Toasts already sit at 200 (below), unaffected.
- Update RightDock dock-toggle test to the current behavior (popping out closes the dock; the floating modal survives) and fix NewTaskModal keyboard-vars test to query the portaled modal from document.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-22 05:52:44 -07:00
parent 56c4d8be7c
commit 1ced957dbf
6 changed files with 28 additions and 15 deletions

View File

@@ -1,5 +1,6 @@
import "./NewTaskModal.css";
import { useState, useCallback, useEffect, useRef, type CSSProperties, type PointerEvent as ReactPointerEvent } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { DEFAULT_TASK_PRIORITY, type Task, type TaskCreateInput, type TaskPriority } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
@@ -746,7 +747,8 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
? { left: `${position.x}px`, top: `${position.y}px`, width: `${size.width}px`, height: `${size.height}px`, zIndex }
: keyboardStyle;
return (
// FNXC:FloatingWindow 2026-06-22-22:30: Portaled to document.body so the floating New Task dialog shares the ONE root stacking context with the other floating modals; the shared cross-type z stack only orders correctly at the document root. Mobile sheet is position:fixed, unaffected.
return createPortal(
<div
className="modal-overlay open new-task-modal-overlay"
onKeyDown={handleKeyDown}
@@ -868,6 +870,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
</button>
</div>
</div>
</div>
</div>,
document.body,
);
}

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type RefObject } from "react";
import { createPortal } from "react-dom";
import { Maximize2, X } from "lucide-react";
import { findOverflowViewEntry, type OverflowViewEntry, type OverflowViewKey, type OverflowViewRenderProps, type OverflowViewVisibilityOptions } from "./overflowViewRegistry";
import { nextFloatingZ, currentFloatingZ } from "./floatingWindowStack";
@@ -303,7 +304,8 @@ export function RightDockExpandModal({
zIndex,
} as CSSProperties;
return (
// FNXC:FloatingWindow 2026-06-22-22:30: Portaled to document.body so this floating modal shares the ONE root stacking context with the other floating modals (FloatingWindow/terminal/New Task) — the shared 10100+ z stack only orders correctly across types when they all live at the document root.
return createPortal(
<div className="modal-overlay open right-dock-expand-modal-overlay" role="dialog" aria-modal="false" aria-label={`${entry.label} expanded`} data-testid="right-dock-expand-modal">
<div
className="modal right-dock-expand-modal right-dock-expand-modal--floating"
@@ -343,6 +345,7 @@ export function RightDockExpandModal({
{entry.render({ ...renderProps, surface: "expand" })}
</div>
</div>
</div>
</div>,
document.body,
);
}

View File

@@ -1,4 +1,5 @@
import "./TerminalModal.css";
import { createPortal } from "react-dom";
import {
useState,
useEffect,
@@ -1823,7 +1824,8 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
: {}),
} as CSSProperties;
return (
// FNXC:FloatingWindow 2026-06-22-22:30: Portaled to document.body so the terminal shares the ONE root stacking context with the other floating modals; the shared cross-type z stack only orders correctly when all panels live at the document root. Docked/floating/mobile are all position:fixed, so portaling does not change their placement.
return createPortal(
<div
className={overlayClassName}
onMouseDown={handleOverlayMouseDown}
@@ -2295,6 +2297,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
</span>
</div>
</div>
</div>
</div>,
document.body,
);
}

View File

@@ -113,8 +113,9 @@ describe("NewTaskModal", () => {
viewportOffsetTop: 50,
});
const { container } = renderNewTaskModal();
const modal = container.querySelector(".new-task-modal");
renderNewTaskModal();
// FNXC: NewTaskModal portals to document.body, so query the modal from document (not the render container).
const modal = document.querySelector(".new-task-modal");
expect(mockUseMobileKeyboard).toHaveBeenCalledWith({ enabled: true });
expect(modal?.getAttribute("style")).toContain("--keyboard-overlap: 250px");

View File

@@ -347,16 +347,18 @@ describe("RightDock", () => {
render(<Harness />);
// Pop out the currently selected (Files) view from the open dock.
// Pop out the currently selected (Files) view: the floating modal appears AND
// popping out closes the dock (pop-out dismisses the dock so the full-width app
// sits behind the movable modal). The dock unmounts; the floating modal survives.
fireEvent.click(screen.getByTestId("right-dock-expand"));
expect(screen.getByTestId("right-dock-expand-modal")).toBeInTheDocument();
// Toggle the dock closed: the dock itself unmounts, the floating modal MUST survive.
fireEvent.click(screen.getByTestId("harness-toggle-dock"));
expect(screen.queryByTestId("right-dock")).toBeNull();
expect(screen.getByTestId("right-dock-expand-modal")).toBeInTheDocument();
expect(screen.getByTestId("right-dock-expand-body")).toBeInTheDocument();
// Re-opening the dock does not disturb the independent floating modal.
fireEvent.click(screen.getByTestId("harness-toggle-dock"));
expect(screen.getByTestId("right-dock-expand-modal")).toBeInTheDocument();
// Its own close button still dismisses it.
fireEvent.click(screen.getByTestId("right-dock-expand-close"));
expect(screen.queryByTestId("right-dock-expand-modal")).toBeNull();

View File

@@ -2,9 +2,10 @@
FNXC:FloatingWindow 2026-06-22-21:30:
SHARED floating-window z-index stack. This is the ONE source of z-index for every floating modal in the dashboard (FloatingWindow, the right-dock pop-out, the floating terminal, the floating New Task dialog) so they interoperate in a SINGLE stack instead of each type owning a private counter. Previously each modal type managed z-index independently, so tapping e.g. the terminal could not raise it above a popped-out task-detail FloatingWindow. Now every floating modal claims `nextFloatingZ()` on mount/open and again on every panel pointerdown/focus, so the most-recently-interacted window is always on top REGARDLESS of type.
Base band sits at 4000+ — above ordinary page content and above the base `.modal-overlay` (z-index 100). The counter is module-level and intentionally monotonic: it only ever climbs, which is fine for a session-length dashboard. All floating overlays are `pointer-events: none` (click-through) so raising panels into this shared band never traps clicks on the page behind them.
FNXC:FloatingWindow 2026-06-22-22:30:
Base band sits at 10100+ — ABOVE the page overlay/popover band (log viewer, workflow-editor modal, selection popover, fullscreen overlay at z 10000-10001) so a floating window the user is dragging is never painted over by those. Transient top-right toasts are bumped to 10500 (styles.css) so system feedback still shows above a dragged window. The counter is module-level and intentionally monotonic: it only ever climbs, which is fine for a session-length dashboard. All floating overlays are `pointer-events: none` (click-through) so raising panels into this shared band never traps clicks on the page behind them. CRITICAL: every floating modal must be portaled to document.body so this shared z is compared in ONE root stacking context (an inline panel cannot beat siblings outside its own context no matter its z).
*/
let topZ = 4000;
let topZ = 10100;
/** Claim the front of the shared floating-window stack. Monotonic, session-length. */
export function nextFloatingZ(): number {