FN-8606: migrate core workflow modals to shared floating windows

Migrate dashboard dialogs to the shared movable and resizable FloatingWindow contract.

- Move core, workflow, Git, planning, and automation modal surfaces to stable floating-window identities with persisted geometry.
- Suspend geometry and floating controls for phone and short-viewport sheets, including Quick Chat.
- Add accessibility wiring, tablet touch targets, migration tests, and operator documentation.

Files changed:
 .changeset/fn-8606-floating-window-core-modals.md  |   7 +
 docs/dashboard-guide.md                            |   4 +
 packages/dashboard/app/App.tsx                     |   8 +-
 .../dashboard/app/components/ActivityLogModal.tsx  |  30 ++--
 packages/dashboard/app/components/AddNodeModal.tsx |   8 +-
 .../dashboard/app/components/ChangesDiffModal.tsx  |  35 +++--
 .../dashboard/app/components/ConnectNodeModal.tsx  |   9 +-
 .../dashboard/app/components/FloatingWindow.css    |  71 ++++++++-
 .../dashboard/app/components/FloatingWindow.tsx    |  27 +++-
 .../dashboard/app/components/GitManagerModal.tsx   |  30 +++-
 .../dashboard/app/components/GroupTaskModal.tsx    |   8 +-
 .../app/components/ModelOnboardingModal.tsx        |  28 ++--
 .../dashboard/app/components/NodeDetailModal.tsx   |   9 +-
 .../dashboard/app/components/PlanningModeModal.css |   1 -
 .../dashboard/app/components/PlanningModeModal.tsx |  57 +++----
 .../app/components/ScheduledTasksModal.tsx         |   9 +-
 packages/dashboard/app/components/ScriptsModal.css |   1 -
 packages/dashboard/app/components/ScriptsModal.tsx |  32 ++--
 .../dashboard/app/components/SettingsModal.css     |   1 -
 .../dashboard/app/components/SettingsModal.tsx     |  58 ++++---
 .../app/components/WorkflowAddStepModal.css        |  15 --
 .../app/components/WorkflowAddStepModal.tsx        |  46 +++---
 .../components/__tests__/ActivityLogModal.test.tsx |  33 ++--
 .../app/components/__tests__/AddNodeModal.test.tsx |  12 +-
 .../components/__tests__/ChangesDiffModal.test.tsx |  78 +++++-----
 .../components/__tests__/ConnectNodeModal.test.tsx |   7 +
 .../components/__tests__/FloatingWindow.test.tsx   | 173 ++++++++++++++++++++-
 .../components/__tests__/GitManagerModal.test.tsx  |  30 ++--
 .../components/__tests__/GroupTaskModal.test.tsx   |   9 ++
 .../__tests__/ModelOnboardingModal.test.tsx        |  18 ++-
 .../components/__tests__/NodeDetailModal.test.tsx  |   9 ++
 .../__tests__/PlanningModeModal.autosize.test.tsx  |  30 ++--
 .../__tests__/ScheduledTasksModal.test.tsx         |  21 ++-
 .../app/components/__tests__/ScriptsModal.test.tsx |  15 +-
 .../__tests__/SettingsModal.mobileClose.test.tsx   |  25 ++-
 .../__tests__/WorkflowAddStepModal.test.tsx        |   7 +
 .../floatingWindowMigration.test-helpers.ts        | 126 +++++++++++++++
 37 files changed, 828 insertions(+), 259 deletions(-)

Fusion-Task-Id: FN-8606
Fusion-Task-Lineage: dab0df2d-73f4-4b0f-bec3-a45016310c91
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-26 14:35:29 -07:00
parent cf5575a319
commit a6885b73f2
37 changed files with 828 additions and 259 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Make core, workflow, and Git dashboard dialogs draggable and resizable on tablets.
category: feature
dev: Migrates 13 modal surfaces to FloatingWindow keys with persisted clamped geometry and removes legacy resize-grip paths.

View File

@@ -96,6 +96,10 @@ Task Detail and New Task remain resizable on known touch tablets, including a 76
Use `FloatingWindow` for a moveable and resizable dashboard surface rather than adding per-modal pointer code. On known tablet touch viewports it uses `isTabletTouchViewport`, applies `data-resize-hit-target="true"` to the drag handle and all eight edge/corner handles, and expands only their hit areas to the shared 44px target without thickening painted borders or covering content/footer controls. Never gate these controls on bare `(pointer: coarse)`: desktop hybrids keep desktop geometry. Phone full-screen sheets are strictly **below 768px** (`max-width: 767.98px`); a 768px viewport is tablet-class, so JS geometry and CSS must preserve active targets there.
<!-- FNXC:ModalTouchGeometryDocs 2026-07-26-13:50: Core/workflow modal migrations use stable window keys so one shared primitive owns drag, resize, clamping, stacking, and persisted geometry. -->
Core/workflow FloatingWindow modals use `persistGeometryKey="floating-window:<windowKey>"`: `automation` (Scheduled Tasks), `settings`, `git-manager`, `planning-mode`, `changes-diff`, `model-onboarding`, `activity-log`, `scripts`, `add-node`, `connect-node`, `node-detail`, `workflow-add-step`, and `group-task`. The former size-only `fusion:settings-modal-size`, `fusion:git-modal-size`, `fusion:planning-modal-size`, `fusion:changes-diff-modal-size`, and `fusion:model-onboarding-modal-size` keys are superseded by their matching complete geometry records. All of these windows suspend reading and writing geometry on phone and short (`max-height: 480px`) sheet viewports, so a desktop position never leaks into the sheet and a sheet never overwrites the desktop choice.
## Mobile/PWA app icons
The installed mobile/PWA home-screen icons are generated from `packages/dashboard/app/public/logo.svg` by the desktop icon generator. When the Fusion brand mark changes, run `pnpm --filter @fusion/desktop generate:icons` so `packages/dashboard/app/public/icons/icon-192.png` and `packages/dashboard/app/public/icons/icon-512.png` stay aligned with the canonical logo. Also bump `CACHE_NAME` in `packages/dashboard/app/public/sw.js` whenever those icon assets change so installed PWAs refresh the cached launcher images.

View File

@@ -2044,8 +2044,14 @@ function AppInner() {
overlapping surface claims the front on pointer/focus. Other utility FloatingWindows keep
their higher utility band, so this scoped opt-in cannot change Terminal, Files, or New Task.
*/
/* FNXC:ModalGeometryPersistence 2026-07-15-19:30: Chat is a full-screen sheet at ≤768px, so preserve its desktop location and size instead of restoring or overwriting them there. */
/*
FNXC:ModalGeometryPersistence 2026-07-26-21:00:
Quick Chat is a full-screen sheet at both the narrow and short-viewport CSS breakpoints.
Suspend desktop geometry restoration, writes, drag, and resize controls for both surfaces
so a short sheet cannot corrupt the desktop window it restores after rotation.
*/
suspendGeometryPersistenceOnMobile
suspendGeometryPersistenceOnShortViewport
persistGeometryKey="kb-dashboard-chat-floating-window"
defaultSize={{ width: 980, height: 680 }}
/*

View File

@@ -13,6 +13,7 @@ import { useEmbeddedPresentation, type ModalPresentation } from "../hooks/useEmb
import type { Task, ProjectInfo } from "@fusion/core";
import { linkifyFilePaths } from "../utils/filePathLinkify";
import { getRelativeTimeBucket } from "../utils/relativeTimeAgo";
import { FloatingWindow } from "./FloatingWindow";
interface ActivityLogModalProps {
isOpen: boolean;
@@ -477,16 +478,27 @@ export function ActivityLogModal({
}
return (
<div
className="modal-overlay open"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
role="dialog"
aria-modal="true"
data-testid="activity-log-modal-overlay"
/*
FNXC:ModalTouchGeometry 2026-07-26-13:20:
Activity Log keeps its embedded return above this branch. The modal branch delegates backdrop
dismissal, drag, resize, clamping, and persistence to the shared FloatingWindow contract.
*/
<FloatingWindow
windowKey="activity-log"
title={t("activityLog.title", "Activity Log")}
ariaLabel={`${t("activityLog.title", "Activity Log")} dialog`}
onClose={onClose}
hideHeader
dragHandleSelector=".activity-log-header"
className="floating-window--activity-log"
defaultSize={{ width: 720, height: 560 }}
minSize={{ width: 360, height: 280 }}
persistGeometryKey="floating-window:activity-log"
suspendGeometryPersistenceOnMobile
suspendGeometryPersistenceOnShortViewport
closeOnOutsidePointerDown
>
{body}
</div>
</FloatingWindow>
);
}

View File

@@ -7,6 +7,7 @@ import type { ToastType } from "../hooks/useToast";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import "./AddNodeModal.css";
import { FloatingWindow } from "./FloatingWindow";
export interface AddNodeInput {
name: string;
type: "local" | "remote";
@@ -255,8 +256,9 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, onDiscoverRemoteProjec
if (!isOpen) return null;
return (
<div className="modal-overlay open" onClick={closeModal}>
<div className="modal modal-md add-node-modal" onClick={(event) => event.stopPropagation()} role="dialog" aria-modal="true" aria-label={t("nodes.addNode", "Add Node")}>
/* FNXC:ModalTouchGeometry 2026-07-26-13:15: Shared FloatingWindow owns this modal's touch drag, resize, clamping, and persistence while phone and short viewports retain their sheet behavior. */
<FloatingWindow windowKey="add-node" title={t("nodes.addNode", "Add Node")} ariaLabel={`${t("nodes.addNode", "Add Node")} dialog`} onClose={closeModal} hideHeader dragHandleSelector=".modal-header" className="floating-window--add-node" defaultSize={{ width: 720, height: 560 }} minSize={{ width: 360, height: 280 }} persistGeometryKey="floating-window:add-node" suspendGeometryPersistenceOnMobile suspendGeometryPersistenceOnShortViewport closeOnOutsidePointerDown>
<div className="modal modal-md add-node-modal" aria-label={t("nodes.addNode", "Add Node")}>
<div className="modal-header">
<h3>{t("nodes.addNode", "Add Node")}</h3>
<button className="modal-close" onClick={closeModal} disabled={isSubmitting} aria-label={t("nodes.closeNodeModal", "Close add node modal")}>
@@ -468,6 +470,6 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, onDiscoverRemoteProjec
</button>
</div>
</div>
</div>
</FloatingWindow>
);
}

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import { FloatingWindow } from "./FloatingWindow";
import { useModalDismissPreference } from "../hooks/useOverlayDismiss";
import {
X,
FileCode,
@@ -68,11 +68,10 @@ export function ChangesDiffModal({
onRefresh,
}: ChangesDiffModalProps) {
const { t } = useTranslation("app");
const dismissOnOutsidePointerDown = useModalDismissPreference();
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const [wordWrap, setWordWrap] = useState(true);
const modalRef = useRef<HTMLDivElement>(null);
useModalResizePersist(modalRef, isOpen, "fusion:changes-diff-modal-size");
const overlayDismissProps = useOverlayDismiss(onClose);
// FNXC:ModalTouchGeometry 2026-07-26-13:30: FloatingWindow supersedes the legacy size-only grip and persists the complete clamped geometry under its stable window key.
// Auto-select first file when files change
useEffect(() => {
@@ -121,11 +120,23 @@ export function ChangesDiffModal({
const isDone = column === "done";
return (
<div className="modal-overlay open" {...overlayDismissProps} role="dialog" aria-modal="true">
<div
className="modal changes-diff-modal"
ref={modalRef}
>
<FloatingWindow
windowKey="changes-diff"
title={t("changes.title", "Changes")}
ariaLabel={`${t("changes.title", "Changes")} dialog`}
onClose={onClose}
hideHeader
dragHandleSelector=".changes-diff-modal-header"
className="floating-window--changes-diff"
defaultSize={{ width: 960, height: 640 }}
minSize={{ width: 360, height: 280 }}
persistGeometryKey="floating-window:changes-diff"
suspendGeometryPersistenceOnMobile
suspendGeometryPersistenceOnShortViewport
/* FNXC:ModalTouchGeometry 2026-07-26-16:10: Keep Changes' historical preference-gated backdrop dismissal while FloatingWindow ignores active drag and resize gestures. */
closeOnOutsidePointerDown={dismissOnOutsidePointerDown}
>
<div className="modal changes-diff-modal">
{/* Header */}
<div className="modal-header changes-diff-modal-header">
<div className="changes-diff-header-title">
@@ -270,6 +281,6 @@ export function ChangesDiffModal({
</div>
</div>
</div>
</div>
</FloatingWindow>
);
}

View File

@@ -3,6 +3,7 @@ import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import type { NodeCreateInput, NodeInfo } from "../api";
import { FloatingWindow } from "./FloatingWindow";
export interface ConnectNodeInput {
name: string;
url: string;
@@ -166,12 +167,10 @@ export function ConnectNodeModal({ open, onClose, onConnected, addToast, onSubmi
if (!open) return null;
return (
<div className="modal-overlay open" onClick={onClose}>
/* FNXC:ModalTouchGeometry 2026-07-26-13:15: Shared FloatingWindow owns this modal's touch drag, resize, clamping, and persistence while phone and short viewports retain their sheet behavior. */
<FloatingWindow windowKey="connect-node" title={t("nodes.modal.title", "Connect to Node")} ariaLabel={`${t("nodes.modal.title", "Connect to Node")} dialog`} onClose={onClose} hideHeader dragHandleSelector=".modal-header" className="floating-window--connect-node" defaultSize={{ width: 720, height: 560 }} minSize={{ width: 360, height: 280 }} persistGeometryKey="floating-window:connect-node" suspendGeometryPersistenceOnMobile suspendGeometryPersistenceOnShortViewport closeOnOutsidePointerDown>
<div
className="modal modal-md connect-node-modal"
onClick={(event) => event.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label={t("nodes.modal.title", "Connect to Node")}
>
<div className="modal-header">
@@ -274,6 +273,6 @@ export function ConnectNodeModal({ open, onClose, onConnected, addToast, onSubmi
</button>
</div>
</div>
</div>
</FloatingWindow>
);
}

View File

@@ -214,15 +214,69 @@ Phone sheets are strictly below 768px. At exactly 768px this query must not matc
FNXC:ChatModal 2026-06-22-14:49:
On mobile/narrow app viewports, opening Quick Chat should present the full Chat modal as a full-screen sheet instead of a small draggable desktop window. Scope this to the chat FloatingWindow and override the inline desktop geometry only at the mobile breakpoint; desktop pop-out behavior remains movable/resizable.
*/
@media (max-width: 767.98px) {
/*
FNXC:ModalTouchGeometry 2026-07-26-13:25:
FN-8606 migrated core and workflow dialogs share FloatingWindow's pointer contract. Their
caller-owned headers remain the drag target on desktop/tablet; true phones and short sheets hide
all floating affordances so persisted desktop geometry cannot imply a draggable sheet.
*/
.floating-window--activity-log .floating-window__body,
.floating-window--scripts .floating-window__body,
.floating-window--add-node .floating-window__body,
.floating-window--connect-node .floating-window__body,
.floating-window--node-detail .floating-window__body,
.floating-window--workflow-add-step .floating-window__body,
.floating-window--group-task .floating-window__body,
.floating-window--changes-diff .floating-window__body,
.floating-window--model-onboarding .floating-window__body,
.floating-window--git-manager .floating-window__body,
.floating-window--settings .floating-window__body,
.floating-window--planning-mode .floating-window__body {
overflow: hidden;
}
.floating-window--activity-log .modal,
.floating-window--scripts .modal,
.floating-window--add-node .modal,
.floating-window--connect-node .modal,
.floating-window--node-detail .modal,
.floating-window--group-task .modal,
.floating-window--workflow-add-step .wf-add-step-dialog,
.floating-window--changes-diff .changes-diff-modal,
.floating-window--model-onboarding .model-onboarding-modal,
.floating-window--git-manager .gm-modal,
.floating-window--settings .settings-modal,
.floating-window--planning-mode .planning-modal {
width: 100%;
height: 100%;
max-width: none;
max-height: none;
border-radius: inherit;
}
@media (max-width: 767.98px), (max-height: 480px) {
/*
FNXC:FloatingWindow 2026-07-12-17:35:
Mobile keeps the global `styles.css` pan-y lockdown so the dashboard cannot drift, but movable FloatingWindow headers must still resolve to an effective `touch-action: none`. Reassert the drag-handle contract at the mobile breakpoint, excluding full-screen sheet variants, so a single-finger header drag stays on the captured pointermove stream instead of being intersected back into page pan by the ancestor chain. Desktop drag/resize and mobile sheet variants are unchanged.
*/
.floating-window:not(.floating-window--chat):not(.floating-window--github-import-detail):not(.floating-window--task-detail):not(.floating-window--workflow-editor):not(.floating-window--automation):not(.floating-window--mission-interview):not(.floating-window--file-browser):not(.floating-window--pr-create):not(.artifacts-gallery-window) .floating-window__header {
.floating-window:not(.floating-window--chat):not(.floating-window--github-import-detail):not(.floating-window--task-detail):not(.floating-window--workflow-editor):not(.floating-window--automation):not(.floating-window--mission-interview):not(.floating-window--file-browser):not(.floating-window--pr-create):not(.floating-window--activity-log):not(.floating-window--scripts):not(.floating-window--add-node):not(.floating-window--connect-node):not(.floating-window--node-detail):not(.floating-window--workflow-add-step):not(.floating-window--group-task):not(.floating-window--changes-diff):not(.floating-window--model-onboarding):not(.floating-window--git-manager):not(.floating-window--settings):not(.floating-window--planning-mode):not(.artifacts-gallery-window) .floating-window__header {
touch-action: none;
}
/* FNXC:ModalTouchGeometry 2026-07-26-16:10: Automations shares every FN-8606 sheet breakpoint: a short viewport must not expose desktop geometry even when it remains wider than a phone. */
.floating-window--automation,
.floating-window--activity-log,
.floating-window--scripts,
.floating-window--add-node,
.floating-window--connect-node,
.floating-window--node-detail,
.floating-window--workflow-add-step,
.floating-window--group-task,
.floating-window--changes-diff,
.floating-window--model-onboarding,
.floating-window--git-manager,
.floating-window--settings,
.floating-window--planning-mode,
.floating-window--chat {
inset: 0 !important;
width: 100vw !important;
@@ -236,6 +290,19 @@ On mobile/narrow app viewports, opening Quick Chat should present the full Chat
box-shadow: none;
}
.floating-window--automation .floating-window__resize-handle,
.floating-window--activity-log .floating-window__resize-handle,
.floating-window--scripts .floating-window__resize-handle,
.floating-window--add-node .floating-window__resize-handle,
.floating-window--connect-node .floating-window__resize-handle,
.floating-window--node-detail .floating-window__resize-handle,
.floating-window--workflow-add-step .floating-window__resize-handle,
.floating-window--group-task .floating-window__resize-handle,
.floating-window--changes-diff .floating-window__resize-handle,
.floating-window--model-onboarding .floating-window__resize-handle,
.floating-window--git-manager .floating-window__resize-handle,
.floating-window--settings .floating-window__resize-handle,
.floating-window--planning-mode .floating-window__resize-handle,
.floating-window--chat .floating-window__resize-handle {
display: none;
}

View File

@@ -72,6 +72,12 @@ export interface FloatingWindowProps {
layer?: "utility" | "task-detail";
// FNXC:FloatingWindow 2026-07-11-11:30: accessible name for the dialog overlay so headerless windows (e.g. artifact viewers with their own header chrome) stay queryable/announcable by label.
ariaLabel?: string;
/*
FNXC:ModalTouchGeometry 2026-07-26-14:09:
Headerless migrated dialogs may own a step-dependent title inside custom chrome. Forward its
id to the shared dialog so screen readers retain that live name instead of a stale seed title.
*/
ariaLabelledBy?: string;
}
const DEFAULT_WIDTH = 720;
@@ -196,6 +202,7 @@ export function FloatingWindow({
hidden = false,
layer = "utility",
ariaLabel,
ariaLabelledBy,
}: FloatingWindowProps) {
const resolvedMinSize: FloatingWindowSize = minSize ?? { width: DEFAULT_MIN_WIDTH, height: DEFAULT_MIN_HEIGHT };
const viewportMode = useViewportMode();
@@ -275,7 +282,14 @@ export function FloatingWindow({
const handleDragPointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if ((event.target as HTMLElement).closest("button")) return;
/*
FNXC:ModalTouchGeometry 2026-07-26-13:35:
FN-8606 sheet callers must expose neither movable geometry nor resize chrome on phone and
short viewports. Do not begin a delegated or built-in header drag while persistence is
suspended; CSS alone cannot prevent the panel-level pointer handler from receiving touches.
*/
/* FNXC:ModalTouchGeometry 2026-07-26-14:20: Delegated headers commonly contain links (for example Settings' GitHub/Discord actions), which must retain native activation rather than starting a window drag. */
if (geometryPersistenceSuspended || (event.target as HTMLElement).closest("button, a, input, select, textarea, [contenteditable=\"true\"], [role=\"button\"], [role=\"link\"]")) return;
event.preventDefault();
event.stopPropagation();
/*
@@ -336,7 +350,7 @@ export function FloatingWindow({
captureTarget.addEventListener("pointerup", handlePointerUp);
captureTarget.addEventListener("pointercancel", handlePointerUp);
},
[bringToFront, position, size]
[bringToFront, geometryPersistenceSuspended, position, size]
);
const handlePanelPointerDown = useCallback(
@@ -535,6 +549,7 @@ export function FloatingWindow({
aria-modal="false"
aria-hidden={hidden || undefined}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
data-testid={`floating-window-overlay-${windowKey}`}
// FNXC:FloatingWindow 2026-06-22-23:00: The z-index MUST live on the position:fixed overlay (which creates a stacking context), not the panel. A panel z-index is trapped inside the overlay's context and loses to page elements that are stacking contexts in body's context (e.g. the right dock at position:absolute z-index:20). With z on the overlay, the whole window sits at the shared floating band in body's stacking context and reliably paints above page content + tap-to-front reorders correctly.
style={{ zIndex }}
@@ -548,7 +563,13 @@ export function FloatingWindow({
onPointerDown={handlePanelPointerDown}
onFocusCapture={bringToFront}
>
{RESIZE_DIRECTIONS.map((direction) => (
{/*
FNXC:ModalTouchGeometry 2026-07-26-16:54:
Phone and short-viewport callers opt into a full-screen sheet. Do not merely hide resize
handles with CSS there: removing them from the accessibility tree ensures those sheets
expose no floating-window affordance or touch gesture surface.
*/}
{!geometryPersistenceSuspended && RESIZE_DIRECTIONS.map((direction) => (
<div
key={direction}
className={`floating-window__resize-handle floating-window__resize-handle--${direction}`}

View File

@@ -6,11 +6,11 @@ import { getErrorMessage } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
import { useConfirm } from "../hooks/useConfirm";
import { getPathBasename } from "../utils/pathDisplay";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import { FloatingWindow } from "./FloatingWindow";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useEmbeddedPresentation, type ModalPresentation } from "../hooks/useEmbeddedPresentation";
import { useModalDismissPreference } from "../hooks/useOverlayDismiss";
import { useViewportMode } from "../hooks/useViewportMode";
import { copyTextToClipboard } from "../utils/copyToClipboard";
import type {
@@ -228,7 +228,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
const confirmContext = useConfirm();
const viewportMode = useViewportMode();
// FNXC:RightDockEmbedding 2026-06-22-00:00: embedded mode gates modal-only behaviors below (shared hook).
const { isEmbedded, scrollLockEnabled, resizePersistEnabled, escapeEnabled } = useEmbeddedPresentation(presentation);
const { isEmbedded, scrollLockEnabled, escapeEnabled } = useEmbeddedPresentation(presentation);
useMobileScrollLock(isOpen && scrollLockEnabled);
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({
enabled: viewportMode === "mobile",
@@ -257,9 +257,9 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
const [loading, setLoading] = useState(false);
const [sectionError, setSectionError] = useState<string | null>(null);
const modalRef = useRef<HTMLDivElement>(null);
const dismissOnOutsidePointerDown = useModalDismissPreference();
// FNXC:RightDockEmbedding 2026-06-22-00:00: skip modal resize persist/restore when embedded inline.
useModalResizePersist(modalRef, isOpen && resizePersistEnabled, "fusion:git-modal-size");
const overlayDismissProps = useOverlayDismiss(handleClose);
// FNXC:ModalTouchGeometry 2026-07-26-13:35: The modal-only branch below uses FloatingWindow for complete touch geometry; the embedded branch keeps its existing inline presentation.
const copyToClipboard = useCopyToClipboard(addToast);
// ── Status state
@@ -1380,8 +1380,24 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
);
}
/* FNXC:ModalTouchGeometry 2026-07-26-14:25: Preserve Git Manager's pre-migration responsive desktop shell as the FloatingWindow seed so shared persistence does not shrink the surface. */
return (
<div className="modal-overlay open git-manager-modal-overlay" {...overlayDismissProps} role="dialog" aria-modal="true">
<FloatingWindow
windowKey="git-manager"
title={t("git.modalTitle", "Git Manager")}
ariaLabel={`${t("git.modalTitle", "Git Manager")} dialog`}
onClose={handleClose}
hideHeader
dragHandleSelector=".modal-header"
className="floating-window--git-manager"
defaultSize={{ width: Math.min(window.innerWidth * 0.95, 1400), height: window.innerHeight * 0.92 }}
minSize={{ width: 360, height: 280 }}
persistGeometryKey="floating-window:git-manager"
suspendGeometryPersistenceOnMobile
suspendGeometryPersistenceOnShortViewport
/* FNXC:ModalTouchGeometry 2026-07-26-16:10: Git Manager keeps the global default-off backdrop preference; FloatingWindow's guarded pointer listener preserves drag-safe outside dismissal. */
closeOnOutsidePointerDown={dismissOnOutsidePointerDown}
>
<div className="modal gm-modal" ref={modalRef} style={keyboardStyle}>
<div className="modal-header">
<h3>
@@ -1399,7 +1415,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
{gitBody}
</div>
</div>
</div>
</FloatingWindow>
);
}

View File

@@ -6,6 +6,7 @@ import { apiAbandonBranchGroup, apiGetBranchGroup, apiPromoteBranchGroup, type B
import { subscribeSse } from "../sse-bus";
import { BRANCH_GROUP_REFRESH_TASK_EVENTS, shouldRefreshBranchGroupForTaskEvent } from "../utils/branchGroupSse";
import { FloatingWindow } from "./FloatingWindow";
interface GroupTaskModalProps {
isOpen: boolean;
onClose: () => void;
@@ -102,8 +103,9 @@ export function GroupTaskModal({ isOpen, onClose, groupId, projectId, onOpenMemb
if (!isOpen || !groupId) return null;
return (
<div className="modal-overlay open" onClick={onClose}>
<div className="modal modal-lg group-task-modal" role="dialog" aria-modal="true" aria-label={t("groupTask.ariaLabel", "Branch group details")} onClick={(event) => event.stopPropagation()}>
/* FNXC:ModalTouchGeometry 2026-07-26-13:15: Shared FloatingWindow owns this modal's touch drag, resize, clamping, and persistence while phone and short viewports retain their sheet behavior. */
<FloatingWindow windowKey="group-task" title={t("groupTask.title", "Branch Group")} ariaLabel={`${t("groupTask.ariaLabel", "Branch group details")} dialog`} onClose={onClose} hideHeader dragHandleSelector=".modal-header" className="floating-window--group-task" defaultSize={{ width: 720, height: 560 }} minSize={{ width: 360, height: 280 }} persistGeometryKey="floating-window:group-task" suspendGeometryPersistenceOnMobile suspendGeometryPersistenceOnShortViewport closeOnOutsidePointerDown>
<div className="modal modal-lg group-task-modal" aria-label={t("groupTask.ariaLabel", "Branch group details")}>
<div className="modal-header">
<h2>{t("groupTask.title", "Branch Group {{id}}", { id: groupId })}</h2>
<button type="button" className="modal-close" onClick={onClose} aria-label={t("actions.closeModal", "Close modal")}>
@@ -186,6 +188,6 @@ export function GroupTaskModal({ isOpen, onClose, groupId, projectId, onOpenMemb
)}
</div>
</div>
</div>
</FloatingWindow>
);
}

View File

@@ -23,7 +23,7 @@ import {
type GitCliStatus,
} from "../api";
import type { ToastType } from "../hooks/useToast";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { FloatingWindow } from "./FloatingWindow";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { ProviderIcon } from "./ProviderIcon";
import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard";
@@ -780,7 +780,6 @@ export function ModelOnboardingModal({
const [shellConnectionError, setShellConnectionError] = useState<string | null>(null);
const apiKeySuccessTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
const onboardingContentRef = useRef<HTMLDivElement | null>(null);
const modalRef = useRef<HTMLDivElement | null>(null);
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const agentErrorRef = useRef<HTMLDivElement | null>(null);
const [loginOutcomes, setLoginOutcomes] = useState<Record<string, LoginOutcome>>({});
@@ -796,7 +795,7 @@ export function ModelOnboardingModal({
const resumedFromStep = persistedState?.currentStep;
const isResumedFlow = !!persistedState && persistedState.currentStep !== "complete";
useModalResizePersist(modalRef, isOpen, "fusion:model-onboarding-modal-size");
// FNXC:ModalTouchGeometry 2026-07-26-13:30: Shared FloatingWindow replaces the legacy resize-only persistence path with clamped movable geometry.
// Scroll the content area to the top whenever the step changes so the user
// always lands at the start of the next page instead of mid-scroll from the
@@ -2436,13 +2435,22 @@ export function ModelOnboardingModal({
};
return (
<div
className="modal-overlay open"
role="dialog"
aria-modal="true"
aria-labelledby="onboarding-title"
<FloatingWindow
windowKey="model-onboarding"
title={t("setup.titleAiSetup", "Set Up AI")}
onClose={handleDismiss}
hideHeader
dragHandleSelector=".model-onboarding-header"
className="floating-window--model-onboarding"
defaultSize={{ width: 720, height: 640 }}
minSize={{ width: 360, height: 280 }}
persistGeometryKey="floating-window:model-onboarding"
suspendGeometryPersistenceOnMobile
suspendGeometryPersistenceOnShortViewport
ariaLabel={`${t("setup.titleAiSetup", "Set Up AI")} dialog`}
ariaLabelledBy="onboarding-title"
>
<div className="modal model-onboarding-modal" ref={modalRef}>
<div className="modal model-onboarding-modal">
{/* Header */}
<div className="model-onboarding-header">
<h2 id="onboarding-title" className="model-onboarding-title">
@@ -3589,6 +3597,6 @@ export function ModelOnboardingModal({
</Suspense>
</ErrorBoundary>
)}
</div>
</FloatingWindow>
);
}

View File

@@ -34,6 +34,7 @@ const DOCKER_MOUNT_LABELS = {
bind: "bind",
} as const;
import { FloatingWindow } from "./FloatingWindow";
interface NodeDetailModalProps {
isOpen: boolean;
onClose: () => void;
@@ -437,12 +438,10 @@ export function NodeDetailModal({
const dockerStatusTone = getDockerStatusTone(effectiveDockerStatus);
return (
<div className="modal-overlay open" onClick={onClose}>
/* FNXC:ModalTouchGeometry 2026-07-26-13:15: Shared FloatingWindow owns this modal's touch drag, resize, clamping, and persistence while phone and short viewports retain their sheet behavior. */
<FloatingWindow windowKey="node-detail" title={t("nodes.modalTitle", "Node Details")} ariaLabel={`${t("nodes.modalAriaLabel", "Node details for {{name}}", { name: node.name })} dialog`} onClose={onClose} hideHeader dragHandleSelector=".modal-header" className="floating-window--node-detail" defaultSize={{ width: 720, height: 560 }} minSize={{ width: 360, height: 280 }} persistGeometryKey="floating-window:node-detail" suspendGeometryPersistenceOnMobile suspendGeometryPersistenceOnShortViewport closeOnOutsidePointerDown>
<div
className="modal modal-lg node-detail-modal"
onClick={(event) => event.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label={t("nodes.modalAriaLabel", "Node details for {{name}}", { name: node.name })}
>
<div className="modal-header">
@@ -891,6 +890,6 @@ export function NodeDetailModal({
addToast={addToast}
/>
)}
</div>
</FloatingWindow>
);
}

View File

@@ -46,7 +46,6 @@
min-height: 480px;
max-height: calc(100dvh - var(--overlay-padding-top, 10vh) - 16px);
overflow: hidden;
resize: both;
}
/*

View File

@@ -1,7 +1,7 @@
import "./PlanningModeModal.css";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { useState, useCallback, useEffect, useRef, useMemo, type CSSProperties, type MouseEvent, type PointerEvent as ReactPointerEvent } from "react";
import { useState, useCallback, useEffect, useRef, useMemo, type CSSProperties, type ReactNode, type PointerEvent as ReactPointerEvent } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Task, PlanningQuestion, PlanningSummary, TaskPriority, ThinkingLevel } from "@fusion/core";
@@ -45,7 +45,7 @@ import {
type PlanningContextualComment,
} from "../api";
import { subscribeSse } from "../sse-bus";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { FloatingWindow } from "./FloatingWindow";
import { useEmbeddedPresentation, type ModalPresentation } from "../hooks/useEmbeddedPresentation";
import {
savePlanningDescription,
@@ -473,7 +473,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
// FNXC:EmbeddedPresentation 2026-06-22-12:00: shared hook supplies isEmbedded (DOM branching) plus the modal-only gates.
// Note: the Escape handler intentionally does NOT gate on embedded here — embedded planning preserves its historical
// Escape-to-close behavior (the back-stack/onClose path), so escapeEnabled is deliberately not wired below.
const { isEmbedded, scrollLockEnabled, resizePersistEnabled } = useEmbeddedPresentation(presentation);
const { isEmbedded, scrollLockEnabled } = useEmbeddedPresentation(presentation);
const [initialPlan, setInitialPlan] = useState("");
/*
FNXC:Planning 2026-07-15-00:00:
@@ -699,11 +699,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const [mobileShowDetail, setMobileShowDetail] = useState<boolean>(Boolean(resumeSessionId));
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const [showArchived, setShowArchived] = useState(false);
// Track whether the mousedown that initiated a click came from inside the
// modal. Resizing via the bottom-right grip can release the mouse outside
// the modal element; without this guard, that release fires a click whose
// target is the overlay and would dismiss the modal mid-resize.
const overlayMouseDownOnSelfRef = useRef(false);
const thinkingOutputRef = useRef<HTMLDivElement>(null);
// Mirrors `streamingOutput` state for reading inside callbacks without
// stale closure issues (e.g. capturing reasoning before onQuestion clears it).
@@ -717,7 +712,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
thinkingLevel?: ThinkingLevel | "";
} | null>(null);
useModalResizePersist(modalRef, isOpen && resizePersistEnabled, "fusion:planning-modal-size");
// FNXC:ModalTouchGeometry 2026-07-26-14:10: FloatingWindow replaces the resize-only grip for the modal branch; embedded Planning keeps its existing inline layout.
const viewportMode = useViewportMode();
const isMobile = viewportMode === "mobile";
/*
@@ -3544,23 +3539,33 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
</section>
);
return (
<div
className={isEmbedded ? "planning-view open" : "modal-overlay open"}
data-testid={isEmbedded ? "planning-view" : undefined}
onMouseDown={isEmbedded ? undefined : (e: MouseEvent<HTMLDivElement>) => {
overlayMouseDownOnSelfRef.current = e.target === e.currentTarget;
}}
onClick={isEmbedded ? undefined : (e: MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget && overlayMouseDownOnSelfRef.current) {
handleClose();
}
overlayMouseDownOnSelfRef.current = false;
}}
role={isEmbedded ? "region" : "dialog"}
aria-label={isEmbedded ? t("planning.title", "Planning Mode") : undefined}
aria-modal={isEmbedded ? undefined : "true"}
/* FNXC:ModalTouchGeometry 2026-07-26-14:25: Preserve Planning Mode's pre-migration responsive desktop shell as the FloatingWindow seed so shared persistence does not shrink the surface. */
const ModalShell = ({ children }: { children: ReactNode }) => isEmbedded ? (
<div className="planning-view open" data-testid="planning-view" role="region" aria-label={t("planning.title", "Planning Mode")}>
{children}
</div>
) : (
<FloatingWindow
windowKey="planning-mode"
title={t("planning.title", "Planning Mode")}
ariaLabel={`${t("planning.title", "Planning Mode")} dialog`}
onClose={handleClose}
hideHeader
dragHandleSelector=".planning-modal > .modal-header"
className="floating-window--planning-mode"
defaultSize={{ width: Math.min(window.innerWidth * 0.95, 1200), height: window.innerHeight * 0.85 }}
minSize={{ width: 360, height: 480 }}
persistGeometryKey="floating-window:planning-mode"
suspendGeometryPersistenceOnMobile
suspendGeometryPersistenceOnShortViewport
closeOnOutsidePointerDown
>
{children}
</FloatingWindow>
);
return (
<ModalShell>
<div className={isEmbedded ? "modal modal-lg planning-modal planning-modal--embedded" : "modal modal-lg planning-modal"} ref={modalRef}>
{/*
FNXC:PlanningMode 2026-06-22-00:00:
@@ -4245,7 +4250,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
</div>
</div>
</div>
</ModalShell>
);
}

View File

@@ -560,14 +560,21 @@ export function ScheduledTasksModal({ onClose, addToast, projectId, presentation
<FloatingWindow
windowKey="automation"
title={t("schedule.title", "Automations")}
ariaLabel={`${t("schedule.title", "Automations")} dialog`}
onClose={onClose}
hideHeader
dragHandleSelector=".automation-modal__drag-handle"
className="floating-window--automation"
defaultSize={{ width: 720, height: 640 }}
minSize={{ width: 420, height: 360 }}
/* FNXC:ModalGeometryPersistence 2026-07-15-19:30: Automations is a full-screen sheet at ≤768px; its movable desktop geometry must survive mobile opens. */
/*
FNXC:ModalTouchGeometry 2026-07-26-13:00:
Automations is the canonical headerless migration: its existing title row owns drag while
FloatingWindow owns touch resize, clamping, stacking, and persistence. Suspend both phone
and short-sheet viewports so saved desktop geometry never leaks into either full-screen form.
*/
suspendGeometryPersistenceOnMobile
suspendGeometryPersistenceOnShortViewport
persistGeometryKey="floating-window:automation"
>
{/**

View File

@@ -2102,7 +2102,6 @@ Theme-level modal backdrop rules can load after component CSS and reapply dim/bl
display: flex;
flex-direction: column;
overflow: hidden;
resize: both;
pointer-events: auto;
}

View File

@@ -5,7 +5,8 @@ import { getErrorMessage } from "@fusion/core";
import { fetchScripts, addScript, removeScript, type ScriptEntry } from "../api";
import type { ToastType } from "../hooks/useToast";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import { useModalDismissPreference } from "../hooks/useOverlayDismiss";
import { FloatingWindow } from "./FloatingWindow";
import {
X,
Plus,
@@ -47,6 +48,7 @@ function truncateCommand(command: string, maxLength: number = 60): string {
export function ScriptsModal({ isOpen, onClose, addToast, projectId, onRunScript }: ScriptsModalProps) {
const { t } = useTranslation("app");
const dismissOnOutsidePointerDown = useModalDismissPreference();
useMobileScrollLock(isOpen);
const [scripts, setScripts] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
@@ -56,7 +58,6 @@ export function ScriptsModal({ isOpen, onClose, addToast, projectId, onRunScript
const [saving, setSaving] = useState(false);
const [deleteConfirmName, setDeleteConfirmName] = useState<string | null>(null);
const [nameError, setNameError] = useState<string | null>(null);
const overlayDismissProps = useOverlayDismiss(onClose);
const loadScripts = useCallback(async () => {
try {
@@ -176,13 +177,24 @@ export function ScriptsModal({ isOpen, onClose, addToast, projectId, onRunScript
}));
return (
<div className="modal-overlay open" {...overlayDismissProps} data-testid="scripts-modal">
<div
className="modal scripts-modal"
role="dialog"
aria-modal="true"
aria-label={t("scripts.title", "Scripts")}
>
/* FNXC:ModalTouchGeometry 2026-07-26-13:20: Scripts retains its existing overlay-dismiss policy while FloatingWindow supplies the only drag/resize implementation and suspends desktop geometry in sheet viewports. */
<FloatingWindow
windowKey="scripts"
title={t("scripts.title", "Scripts")}
ariaLabel={`${t("scripts.title", "Scripts")} dialog`}
onClose={onClose}
hideHeader
dragHandleSelector=".modal-header"
className="floating-window--scripts"
defaultSize={{ width: 720, height: 560 }}
minSize={{ width: 360, height: 280 }}
persistGeometryKey="floating-window:scripts"
suspendGeometryPersistenceOnMobile
suspendGeometryPersistenceOnShortViewport
/* FNXC:ModalTouchGeometry 2026-07-26-16:10: Preserve Scripts' globally default-off backdrop preference while the shared window keeps drag gestures from being mistaken for outside dismissals. */
closeOnOutsidePointerDown={dismissOnOutsidePointerDown}
>
<div className="modal scripts-modal" aria-label={t("scripts.title", "Scripts")}>
{/* Header */}
<div className="modal-header">
<h2>
@@ -490,6 +502,6 @@ export function ScriptsModal({ isOpen, onClose, addToast, projectId, onRunScript
)}
</div>
</div>
</div>
</FloatingWindow>
);
}

View File

@@ -120,7 +120,6 @@
min-height: 480px;
max-height: calc(100dvh - var(--overlay-padding-top, 10vh) - 16px);
overflow: hidden;
resize: both;
}
/*

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useMemo, useRef, type CSSProperties, type Dispatch, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent, type PointerEvent as ReactPointerEvent, type SetStateAction } from "react";
import { useState, useEffect, useCallback, useMemo, useRef, type CSSProperties, type Dispatch, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, type SetStateAction } from "react";
import { Globe, Folder, GitBranch, Power, RefreshCw, Star, Settings as SettingsIcon, Search, X as SearchToggleCloseIcon } from "lucide-react";
import {
getErrorMessage,
@@ -66,14 +66,13 @@ import { DatabaseBackupsSection } from "./settings/sections/DatabaseBackupsSecti
import { LoadingSpinner } from "./LoadingSpinner";
import { PluginsSection } from "./settings/sections/PluginsSection";
import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import type { ToastType } from "../hooks/useToast";
import { useTranslation } from "react-i18next";
import { useSessionBannersHidden, setSessionBannersHidden } from "../hooks/useSessionBannerPref";
import "./SettingsModal.css";
import { FileBrowser } from "./FileBrowser";
import { useWorkspaceFileBrowser } from "../hooks/useWorkspaceFileBrowser";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { FloatingWindow } from "./FloatingWindow";
import { ProviderIcon } from "./ProviderIcon";
import { generateUniquePresetId } from "../utils/modelPresets";
import { copyTextToClipboard } from "../utils/copyToClipboard";
@@ -1122,7 +1121,7 @@ export function SettingsModal({
onOpenWorkflowSettings,
presentation = "modal",
}: SettingsModalProps) {
const { isEmbedded, scrollLockEnabled, resizePersistEnabled, escapeEnabled, overlayDismissEnabled } = useEmbeddedPresentation(presentation);
const { isEmbedded, scrollLockEnabled, escapeEnabled, overlayDismissEnabled } = useEmbeddedPresentation(presentation);
const { t } = useTranslation("app");
const { confirm } = useConfirm();
const viewportMode = useViewportMode();
@@ -1138,7 +1137,6 @@ export function SettingsModal({
...(viewportHeight !== null ? { "--vv-height": `${viewportHeight}px` } : {}),
} as CSSProperties)
: {};
const modalRef = useRef<HTMLDivElement>(null);
const settingsContentRef = useRef<HTMLDivElement>(null);
const workflowLaneSaverRef = useRef<SectionSaveHandler | null>(null);
/*
@@ -1162,8 +1160,7 @@ export function SettingsModal({
workflowLaneSaverRef.current = saver;
}
}, []);
// Modal-only: persist user-resized dialog dimensions. Embedded view fills its host and is not resizable.
useModalResizePersist(modalRef, resizePersistEnabled, "fusion:settings-modal-size");
// FNXC:ModalTouchGeometry 2026-07-26-14:10: FloatingWindow owns movable, clamped geometry for the modal branch; the embedded Settings view remains an inline, chrome-free destination.
const sessionBannersHidden = useSessionBannersHidden();
const [form, setForm] = useState<SettingsFormState>({
maxConcurrent: 2,
@@ -3909,14 +3906,6 @@ export function SettingsModal({
return () => document.removeEventListener("keydown", handleKey);
}, [escapeEnabled, requestClose, resetDialogOpen]);
const modalOverlayDismissProps = useOverlayDismiss(() => { void requestClose(); });
/*
FNXC:SettingsAutoSave 2026-08-02-21:45:
Backdrop dismissal remains preference-gated, but every enabled modal path
shares requestClose so its latest dirty snapshot is flushed before unmount.
*/
const overlayDismissProps = !isEmbedded && overlayDismissEnabled ? modalOverlayDismissProps : {};
/*
FNXC:SettingsReset 2026-07-04-00:25:
@@ -4630,18 +4619,39 @@ export function SettingsModal({
FNXC:Settings 2026-06-22-00:00:
Embedded settings is a main-content destination, not a dialog. It drops the fixed `.modal-overlay` backdrop and the inner card chrome (modal-overlay/modal/settings-modal classes), and instead uses `settings-embedded right-dock-embedded-view` (host) + `settings-modal--embedded` (panel) to fill the pane flush like other embedded views (Planning, Command Center). The modal path stays byte-identical.
*/
return (
const ModalShell = ({ children }: { children: ReactNode }) => isEmbedded ? (
<div
className={isEmbedded ? "settings-embedded right-dock-embedded-view" : "modal-overlay open settings-modal-overlay"}
{...overlayDismissProps}
data-testid={isEmbedded ? "settings-view" : undefined}
role={isEmbedded ? "region" : "dialog"}
aria-label={isEmbedded ? t("settings.title", "Settings") : undefined}
aria-modal={isEmbedded ? undefined : "true"}
className="settings-embedded right-dock-embedded-view"
data-testid="settings-view"
role="region"
aria-label={t("settings.title", "Settings")}
>
{children}
</div>
) : (
<FloatingWindow
windowKey="settings"
title={t("settings.title", "Settings")}
ariaLabel={`${t("settings.title", "Settings")} dialog`}
onClose={() => void requestClose()}
hideHeader
dragHandleSelector=".settings-modal > .modal-header"
className="floating-window--settings"
defaultSize={{ width: 1100, height: 720 }}
minSize={{ width: 520, height: 480 }}
persistGeometryKey="floating-window:settings"
suspendGeometryPersistenceOnMobile
suspendGeometryPersistenceOnShortViewport
closeOnOutsidePointerDown={overlayDismissEnabled}
>
{children}
</FloatingWindow>
);
return (
<ModalShell>
<div
className={isEmbedded ? "modal modal-lg settings-modal settings-modal--embedded" : "modal modal-lg settings-modal"}
ref={modalRef}
style={isEmbedded ? undefined : keyboardStyle}
>
<div className={isEmbedded ? "modal-header modal-header--embedded" : "modal-header"}>
@@ -5381,7 +5391,7 @@ export function SettingsModal({
</div>
</div>
)}
</div>
</ModalShell>
);
}

View File

@@ -5,17 +5,6 @@ full-width bottom sheet on mobile (mirrors the app's mobile modal pattern) so
the same dialog serves both breakpoints.
*/
.wf-add-step-overlay {
position: fixed;
inset: 0;
z-index: 1300;
display: flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--bg) 62%, transparent);
backdrop-filter: blur(2px);
}
.wf-add-step-dialog {
display: flex;
flex-direction: column;
@@ -212,10 +201,6 @@ the same dialog serves both breakpoints.
}
@media (max-width: 768px), (max-height: 480px) {
.wf-add-step-overlay {
align-items: flex-end;
}
.wf-add-step-dialog {
width: 100vw;
max-height: 82dvh;

View File

@@ -4,6 +4,7 @@ import { X, Search, Puzzle, ToggleRight } from "lucide-react";
import type { WorkflowDefinition, WorkflowStepTemplate } from "@fusion/core";
import type { WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes";
import { nodeHelpFor } from "./nodes/node-help";
import { FloatingWindow } from "./FloatingWindow";
import "./WorkflowAddStepModal.css";
/*
@@ -127,28 +128,35 @@ export function WorkflowAddStepModal({
filteredFragments.length > 0 || filteredStepTemplates.length > 0 || filteredPluginTemplates.length > 0;
const hasAnyResult = categories.length > 0 || hasTemplates;
useEffect(() => {
if (!open) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose, open]);
if (!open) return null;
return (
<div
className="wf-add-step-overlay"
data-testid="wf-add-step-modal"
role="presentation"
onClick={onClose}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.stopPropagation();
onClose();
}
}}
/* FNXC:ModalTouchGeometry 2026-07-26-13:20: The workflow palette uses the shared touch-actuable geometry primitive instead of its bespoke overlay; the existing header stays the drag handle. */
<FloatingWindow
windowKey="workflow-add-step"
title={t("workflowNodes.addStepTitle", "Add a step")}
ariaLabel={`${t("workflowNodes.addStepTitle", "Add a step")} dialog`}
onClose={onClose}
hideHeader
dragHandleSelector=".wf-add-step-header"
className="floating-window--workflow-add-step"
defaultSize={{ width: 640, height: 560 }}
minSize={{ width: 360, height: 280 }}
persistGeometryKey="floating-window:workflow-add-step"
suspendGeometryPersistenceOnMobile
suspendGeometryPersistenceOnShortViewport
closeOnOutsidePointerDown
>
<div
className="wf-add-step-dialog"
role="dialog"
aria-modal="true"
aria-label={t("workflowNodes.addStepTitle", "Add a step")}
onClick={(e) => e.stopPropagation()}
>
<div className="wf-add-step-dialog" aria-label={t("workflowNodes.addStepTitle", "Add a step")}>
<header className="wf-add-step-header">
<h3>{t("workflowNodes.addStepTitle", "Add a step")}</h3>
<button
@@ -297,6 +305,6 @@ export function WorkflowAddStepModal({
)}
</div>
</div>
</div>
</FloatingWindow>
);
}

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { ActivityLogModal } from "../ActivityLogModal";
import { assertModalGeometryRecoveryAndSheetContracts, assertRenderedModalTouchGeometry } from "./floatingWindowMigration.test-helpers";
import * as apiModule from "../../api";
import type { ActivityLogEntry, Task } from "@fusion/core";
@@ -130,7 +131,7 @@ describe("ActivityLogModal", () => {
{ id: "older", timestamp: "2026-06-10T20:00:00.000Z", type: "task:created", details: "older" },
] as ActivityLogEntry[]);
const { container } = render(
const { baseElement } = render(
<ActivityLogModal
isOpen={true}
onClose={mockOnClose}
@@ -140,7 +141,7 @@ describe("ActivityLogModal", () => {
);
await waitFor(() => {
const times = Array.from(container.querySelectorAll(".activity-log-entry-time")).map((node) => node.textContent);
const times = Array.from(baseElement.querySelectorAll(".activity-log-entry-time")).map((node) => node.textContent);
expect(times).toEqual(expect.arrayContaining([
"Just now",
"5m ago",
@@ -168,7 +169,7 @@ describe("ActivityLogModal", () => {
},
]);
const { container } = render(
const { baseElement } = render(
<ActivityLogModal
isOpen={true}
onClose={mockOnClose}
@@ -178,12 +179,12 @@ describe("ActivityLogModal", () => {
);
await waitFor(() => {
const labels = Array.from(container.querySelectorAll(".activity-log-entry-type")).map((node) => node.textContent);
const labels = Array.from(baseElement.querySelectorAll(".activity-log-entry-type")).map((node) => node.textContent);
expect(labels).toContain("Task Auto-Archived (Ghost Bug)");
expect(labels).toContain("Task Auto-Archived (Duplicate)");
});
const icons = container.querySelectorAll(".activity-log-entry-icon .activity-icon");
const icons = baseElement.querySelectorAll(".activity-log-entry-icon .activity-icon");
expect(icons).toHaveLength(2);
});
@@ -550,7 +551,7 @@ describe("ActivityLogModal", () => {
// ── Responsive Layout Regression Tests ───────────────────────────
it("renders all mobile-responsive CSS classes on the modal structure", async () => {
const { container } = render(
const { baseElement } = render(
<ActivityLogModal
isOpen={true}
onClose={mockOnClose}
@@ -565,7 +566,7 @@ describe("ActivityLogModal", () => {
});
// Verify key structural classes that the mobile CSS targets
const modal = container.querySelector(".activity-log-modal");
const modal = baseElement.querySelector(".activity-log-modal");
expect(modal).toBeTruthy();
// Modal uses shared modal-lg for consistent wide sizing
expect(modal!.classList.contains("modal-lg")).toBe(true);
@@ -580,7 +581,7 @@ describe("ActivityLogModal", () => {
});
it("renders close button with shared modal-close class and accessibility attributes", async () => {
const { container } = render(
const { baseElement } = render(
<ActivityLogModal
isOpen={true}
onClose={mockOnClose}
@@ -596,16 +597,16 @@ describe("ActivityLogModal", () => {
// Has accessibility label
expect(closeButton.getAttribute("aria-label")).toBe("Close");
// Close button is a direct child of the header, NOT inside the actions row
const header = container.querySelector(".modal-header");
const header = baseElement.querySelector(".modal-header");
expect(header).toBeTruthy();
expect(header!.contains(closeButton)).toBe(true);
const actions = container.querySelector(".activity-log-actions");
const actions = baseElement.querySelector(".activity-log-actions");
expect(actions).toBeTruthy();
expect(actions!.contains(closeButton)).toBe(false);
});
it("renders entry header and details within each entry for mobile reflow", async () => {
const { container } = render(
const { baseElement } = render(
<ActivityLogModal
isOpen={true}
onClose={mockOnClose}
@@ -619,7 +620,7 @@ describe("ActivityLogModal", () => {
});
// Each entry should have the inner structure that mobile CSS reflows
const entries = container.querySelectorAll(".activity-log-entry");
const entries = baseElement.querySelectorAll(".activity-log-entry");
for (const entry of entries) {
expect(entry.querySelector(".activity-log-entry-icon")).toBeTruthy();
expect(entry.querySelector(".activity-log-entry-content")).toBeTruthy();
@@ -680,3 +681,11 @@ describe("ActivityLogModal", () => {
expect(actions!.querySelector(".activity-log-confirm-clear")).toBeTruthy();
});
});
describe("ActivityLogModal floating geometry", () => {
it("uses its production header for touch drag and resize", () => {
render(<ActivityLogModal isOpen onClose={vi.fn()} tasks={[]} onOpenTaskDetail={vi.fn()} />);
assertRenderedModalTouchGeometry("activity-log", screen.getByText("Activity Log").closest(".modal-header") as HTMLElement);
assertModalGeometryRecoveryAndSheetContracts("activity-log", () => render(<ActivityLogModal isOpen onClose={vi.fn()} tasks={[]} onOpenTaskDetail={vi.fn()} />));
});
});

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { AddNodeModal } from "../AddNodeModal";
import { assertModalGeometryRecoveryAndSheetContracts, assertRenderedModalTouchGeometry } from "./floatingWindowMigration.test-helpers";
describe("AddNodeModal", () => {
const defaultProps = {
@@ -217,8 +218,7 @@ describe("AddNodeModal", () => {
render(<AddNodeModal {...defaultProps} />);
// Click on the overlay (not the modal itself)
const overlay = screen.getByRole("dialog").parentElement!;
fireEvent.click(overlay);
fireEvent.pointerDown(document.body);
expect(defaultProps.onClose).toHaveBeenCalled();
});
@@ -458,3 +458,11 @@ describe("AddNodeModal", () => {
expect(screen.queryByDisplayValue("/workspace/project-one")).not.toBeInTheDocument();
});
});
describe("AddNodeModal floating geometry", () => {
it("uses its production header for touch drag and resize", () => {
render(<AddNodeModal isOpen onClose={vi.fn()} onSubmit={vi.fn()} onDiscoverRemoteProjects={vi.fn()} addToast={vi.fn()} projects={[]} />);
assertRenderedModalTouchGeometry("add-node", screen.getByTestId("floating-window-add-node").querySelector(".modal-header") as HTMLElement);
assertModalGeometryRecoveryAndSheetContracts("add-node", () => render(<AddNodeModal isOpen onClose={vi.fn()} onSubmit={vi.fn()} onDiscoverRemoteProjects={vi.fn()} addToast={vi.fn()} projects={[]} />));
});
});

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { loadAllAppCss } from "../../test/cssFixture";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { ChangesDiffModal, type NormalizedFile } from "../ChangesDiffModal";
import { assertModalGeometryRecoveryAndSheetContracts, assertRenderedModalTouchGeometry } from "./floatingWindowMigration.test-helpers";
import { ModalDismissPreferenceProvider } from "../../hooks/useOverlayDismiss";
import type { MergeDetails } from "@fusion/core";
@@ -98,23 +99,23 @@ describe("ChangesDiffModal", () => {
});
it("shows status badges with correct labels", () => {
const { container } = render(
const { baseElement } = render(
<ChangesDiffModal {...defaultProps} />,
);
const modifiedBadge = container.querySelector(
const modifiedBadge = baseElement.querySelector(
".changes-file-status--modified",
);
expect(modifiedBadge).toBeTruthy();
expect(modifiedBadge?.textContent).toBe("M");
const addedBadge = container.querySelector(
const addedBadge = baseElement.querySelector(
".changes-file-status--added",
);
expect(addedBadge).toBeTruthy();
expect(addedBadge?.textContent).toBe("A");
const deletedBadge = container.querySelector(
const deletedBadge = baseElement.querySelector(
".changes-file-status--deleted",
);
expect(deletedBadge).toBeTruthy();
@@ -140,11 +141,11 @@ describe("ChangesDiffModal", () => {
});
it("shows diff content for selected file", () => {
const { container } = render(
const { baseElement } = render(
<ChangesDiffModal {...defaultProps} />,
);
// The first file's patch should be visible
const patchEl = container.querySelector(".changes-diff-patch");
const patchEl = baseElement.querySelector(".changes-diff-patch");
expect(patchEl).toBeTruthy();
expect(patchEl?.textContent).toContain("@@ -1,3 +1,6 @@");
});
@@ -197,11 +198,11 @@ describe("ChangesDiffModal", () => {
});
it("sets title tooltips on diff file path elements", () => {
const { container } = render(<ChangesDiffModal {...defaultProps} />);
const { baseElement } = render(<ChangesDiffModal {...defaultProps} />);
const firstPath = container.querySelector('.changes-diff-file-path[title="src/app.ts"]');
const secondPath = container.querySelector('.changes-diff-file-path[title="src/new-file.ts"]');
const thirdPath = container.querySelector('.changes-diff-file-path[title="src/deleted.ts"]');
const firstPath = baseElement.querySelector('.changes-diff-file-path[title="src/app.ts"]');
const secondPath = baseElement.querySelector('.changes-diff-file-path[title="src/new-file.ts"]');
const thirdPath = baseElement.querySelector('.changes-diff-file-path[title="src/deleted.ts"]');
expect(firstPath).toBeTruthy();
expect(secondPath).toBeTruthy();
@@ -227,12 +228,12 @@ describe("ChangesDiffModal", () => {
});
it("highlights the selected file in sidebar", () => {
const { container } = render(
const { baseElement } = render(
<ChangesDiffModal {...defaultProps} />,
);
// First file should be selected by default
const selectedItems = container.querySelectorAll(
const selectedItems = baseElement.querySelectorAll(
".changes-diff-file-item.selected",
);
expect(selectedItems.length).toBe(1);
@@ -241,7 +242,7 @@ describe("ChangesDiffModal", () => {
// Click on second file
fireEvent.click(screen.getByText("src/new-file.ts"));
const newSelectedItems = container.querySelectorAll(
const newSelectedItems = baseElement.querySelectorAll(
".changes-diff-file-item.selected",
);
expect(newSelectedItems.length).toBe(1);
@@ -340,7 +341,7 @@ describe("ChangesDiffModal", () => {
});
it("toggles word wrap OFF when clicked", () => {
const { container } = render(
const { baseElement } = render(
<ChangesDiffModal {...defaultProps} />,
);
@@ -348,7 +349,7 @@ describe("ChangesDiffModal", () => {
fireEvent.click(toggle);
// Should now have nowrap class
const patchEl = container.querySelector(".changes-diff-patch");
const patchEl = baseElement.querySelector(".changes-diff-patch");
expect(patchEl?.classList.contains("changes-diff-patch--nowrap")).toBe(
true,
);
@@ -382,46 +383,35 @@ describe("ChangesDiffModal", () => {
it("calls onClose when clicking the modal overlay", () => {
const onClose = vi.fn();
/*
FNXC:ChangesDiffModal 2026-07-07-09:20:
FN-7261 (global modal dismissal setting) made backdrop dismissal default-off: useOverlayDismiss only closes when ModalDismissPreferenceProvider enables it. Wrap the render in the provider so the overlay-click dismiss path is exercised (matches AgentErrorDetailsModal.test.tsx).
*/
const { container } = render(
const { baseElement } = render(
<ModalDismissPreferenceProvider enabled>
<ChangesDiffModal {...defaultProps} onClose={onClose} />
</ModalDismissPreferenceProvider>,
);
const overlay = container.querySelector(".modal-overlay");
expect(overlay).toBeTruthy();
// Overlay dismiss is wired via mousedown→mouseup so a resize-drag that
// ends on the overlay doesn't close the modal. A real click on the
// overlay fires both events on the overlay element.
fireEvent.mouseDown(overlay!);
fireEvent.mouseUp(overlay!);
expect(baseElement.querySelector(".floating-window--changes-diff")).toBeTruthy();
fireEvent.pointerDown(document.body);
expect(onClose).toHaveBeenCalledTimes(1);
});
it("does NOT call onClose when mousedown is on the modal but mouseup is on the overlay (resize drag)", () => {
const onClose = vi.fn();
const { container } = render(
const { baseElement } = render(
<ChangesDiffModal {...defaultProps} onClose={onClose} />,
);
const overlay = container.querySelector(".modal-overlay")!;
const modal = container.querySelector(".changes-diff-modal")!;
fireEvent.mouseDown(modal);
fireEvent.mouseUp(overlay);
const modal = baseElement.querySelector(".changes-diff-modal")!;
fireEvent.pointerDown(modal);
expect(onClose).not.toHaveBeenCalled();
});
it("does NOT call onClose when clicking inside the modal body", () => {
const onClose = vi.fn();
const { container } = render(
const { baseElement } = render(
<ChangesDiffModal {...defaultProps} onClose={onClose} />,
);
const modal = container.querySelector(".modal.changes-diff-modal");
const modal = baseElement.querySelector(".modal.changes-diff-modal");
expect(modal).toBeTruthy();
fireEvent.click(modal!);
expect(onClose).not.toHaveBeenCalled();
@@ -492,7 +482,7 @@ describe("ChangesDiffModal", () => {
});
it("does not show commit metadata when column is not done", () => {
const { container } = render(
const { baseElement } = render(
<ChangesDiffModal
{...defaultProps}
column="in-progress"
@@ -500,15 +490,15 @@ describe("ChangesDiffModal", () => {
/>,
);
expect(container.querySelector(".commit-diff-meta")).toBeNull();
expect(baseElement.querySelector(".commit-diff-meta")).toBeNull();
});
it("does not show commit metadata when mergeDetails not provided", () => {
const { container } = render(
const { baseElement } = render(
<ChangesDiffModal {...defaultProps} column="done" />,
);
expect(container.querySelector(".commit-diff-meta")).toBeNull();
expect(baseElement.querySelector(".commit-diff-meta")).toBeNull();
});
it("shows partial commit metadata (SHA only)", () => {
@@ -580,11 +570,11 @@ describe("ChangesDiffModal", () => {
describe("modal height constraint regression", () => {
it("max-height uses calc() to stay within viewport padding", async () => {
const { container } = render(
const { baseElement } = render(
<ChangesDiffModal {...defaultProps} />,
);
const modal = container.querySelector(".changes-diff-modal");
const modal = baseElement.querySelector(".changes-diff-modal");
expect(modal).toBeTruthy();
// Load the stylesheet and verify the .changes-diff-modal rule contains
@@ -639,3 +629,11 @@ describe("ChangesDiffModal", () => {
});
});
});
describe("ChangesDiffModal floating geometry", () => {
it("uses its production header for touch drag and resize", () => {
render(<ChangesDiffModal {...defaultProps} />);
assertRenderedModalTouchGeometry("changes-diff", screen.getByTestId("floating-window-changes-diff").querySelector(".modal-header") as HTMLElement);
assertModalGeometryRecoveryAndSheetContracts("changes-diff", () => render(<ChangesDiffModal {...defaultProps} />));
});
});

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { ConnectNodeModal } from "../ConnectNodeModal";
import { assertModalGeometryRecoveryAndSheetContracts, assertRenderedModalTouchGeometry } from "./floatingWindowMigration.test-helpers";
import type { NodeInfo } from "../../api";
const mockFetch = vi.fn();
@@ -47,6 +48,12 @@ describe("ConnectNodeModal", () => {
expect(screen.getByPlaceholderText("192.0.2.10 or my-server.local")).toBeInTheDocument();
});
it("uses its real header as a touch-actuable FloatingWindow drag handle", () => {
render(<ConnectNodeModal {...defaultProps} />);
assertRenderedModalTouchGeometry("connect-node", screen.getByText("Connect to Node").closest(".modal-header") as HTMLElement);
assertModalGeometryRecoveryAndSheetContracts("connect-node", () => render(<ConnectNodeModal {...defaultProps} />));
});
it("does not render when closed", () => {
render(<ConnectNodeModal {...defaultProps} open={false} />);

View File

@@ -1,15 +1,23 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { readFileSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { loadAllAppCss, loadStylesCss } from "../../test/cssFixture";
import { FLOATING_WINDOW_GEOMETRY_CHANGE_EVENT, FloatingWindow } from "../FloatingWindow";
import { readAppFile } from "../../test/cssFixture";
import { dragWithTouch, expectFloatingWindowStructure, resizeWithTouch } from "./floatingWindowMigration.test-helpers";
const floatingWindowCss = readAppFile("components/FloatingWindow.css");
const chatViewCss = readAppFile("components/ChatView.css");
const allAppCss = loadAllAppCss();
const stylesCss = loadStylesCss();
const FN_8606_WINDOW_IDENTITIES = [
["ActivityLogModal.tsx", "activity-log"], ["ScriptsModal.tsx", "scripts"], ["ScheduledTasksModal.tsx", "automation"],
["SettingsModal.tsx", "settings"], ["GitManagerModal.tsx", "git-manager"], ["PlanningModeModal.tsx", "planning-mode"],
["ChangesDiffModal.tsx", "changes-diff"], ["ModelOnboardingModal.tsx", "model-onboarding"], ["AddNodeModal.tsx", "add-node"],
["ConnectNodeModal.tsx", "connect-node"], ["NodeDetailModal.tsx", "node-detail"], ["WorkflowAddStepModal.tsx", "workflow-add-step"],
["GroupTaskModal.tsx", "group-task"],
] as const;
const QUICK_CHAT_PORTALED_MENU_CLASSES = [
"model-combobox-dropdown--portal",
"model-nested-menu--portal",
@@ -137,10 +145,7 @@ describe("FloatingWindow", () => {
</FloatingWindow>
);
expect(screen.getByTestId("floating-window-drag-handle-beta")).toBeTruthy();
// 8 edge/corner resize handles.
for (const dir of ["n", "s", "e", "w", "ne", "nw", "se", "sw"]) {
expect(screen.getByTestId(`floating-window-resize-${dir}`)).toBeTruthy();
}
expectFloatingWindowStructure("beta");
});
it("keeps every shared floating-window scrollbar inboard of the right resize hot zones", () => {
@@ -285,7 +290,7 @@ describe("FloatingWindow", () => {
expect(allAppCss).toContain("* {");
expect(allAppCss).toContain("#root {");
const movableFloatingWindowSelector = ".floating-window:not(.floating-window--chat):not(.floating-window--github-import-detail):not(.floating-window--task-detail):not(.floating-window--workflow-editor):not(.floating-window--automation):not(.floating-window--mission-interview):not(.floating-window--file-browser):not(.floating-window--pr-create):not(.artifacts-gallery-window) .floating-window__header";
const movableFloatingWindowSelector = ".floating-window:not(.floating-window--chat):not(.floating-window--github-import-detail):not(.floating-window--task-detail):not(.floating-window--workflow-editor):not(.floating-window--automation):not(.floating-window--mission-interview):not(.floating-window--file-browser):not(.floating-window--pr-create):not(.floating-window--activity-log):not(.floating-window--scripts):not(.floating-window--add-node):not(.floating-window--connect-node):not(.floating-window--node-detail):not(.floating-window--workflow-add-step):not(.floating-window--group-task):not(.floating-window--changes-diff):not(.floating-window--model-onboarding):not(.floating-window--git-manager):not(.floating-window--settings):not(.floating-window--planning-mode):not(.artifacts-gallery-window) .floating-window__header";
expect(cssRuleFor(floatingWindowCss, movableFloatingWindowSelector)).toContain("touch-action: none;");
for (const selector of [
@@ -296,6 +301,18 @@ describe("FloatingWindow", () => {
}
});
it("maps every FN-8606 modal to the required shared window identity and sheet suspension", () => {
for (const [file, windowKey] of FN_8606_WINDOW_IDENTITIES) {
const source = readAppFile(`components/${file}`);
expect(source, file).toContain(`<FloatingWindow`);
expect(source, file).toContain(`windowKey=\"${windowKey}\"`);
expect(source, file).toContain(`className=\"floating-window--${windowKey}\"`);
expect(source, file).toContain(`persistGeometryKey=\"floating-window:${windowKey}\"`);
expect(source, file).toContain("suspendGeometryPersistenceOnMobile");
expect(source, file).toContain("suspendGeometryPersistenceOnShortViewport");
}
});
it("gives only delegated Quick Chat headers a larger tablet touch target", () => {
const tabletRule = mediaBlockFor(
chatViewCss,
@@ -835,6 +852,34 @@ describe("FloatingWindow", () => {
expect(JSON.parse(localStorage.getItem(key) ?? "{}")).toEqual({ size: { width: 620, height: 450 }, position: { x: 100, y: 80 } });
});
it("suppresses header drag and persistence in an opt-in short sheet", () => {
const key = "floating-window:short-sheet-gesture";
setSheetViewport(false);
render(
<FloatingWindow
windowKey="short-sheet-gesture"
title="Short sheet"
onClose={() => {}}
persistGeometryKey={key}
suspendGeometryPersistenceOnMobile
suspendGeometryPersistenceOnShortViewport
defaultPosition={{ x: 80, y: 90 }}
>
<div>short sheet body</div>
</FloatingWindow>,
);
const panel = screen.getByTestId("floating-window-short-sheet-gesture");
const header = screen.getByTestId("floating-window-drag-handle-short-sheet-gesture");
fireEvent.pointerDown(header, { pointerId: 91, pointerType: "touch", clientX: 100, clientY: 100 });
fireEvent.pointerMove(header, { pointerId: 91, pointerType: "touch", clientX: 160, clientY: 150 });
fireEvent.pointerUp(header, { pointerId: 91, pointerType: "touch", clientX: 160, clientY: 150 });
expect(panel.style.left).toBe("80px");
expect(panel.style.top).toBe("90px");
expect(localStorage.getItem(key)).toBeNull();
});
it("continues persistence at sheet width when suspension is not opted in", () => {
const key = "floating-window:sheet-default";
const geometry = { size: { width: 610, height: 440 }, position: { x: 90, y: 72 } };
@@ -1000,8 +1045,122 @@ describe("FloatingWindow", () => {
window.removeEventListener(FLOATING_WINDOW_GEOMETRY_CHANGE_EVENT, geometryEvents);
});
/*
FNXC:ModalTouchGeometry 2026-07-26-14:15:
FN-8606 has thirteen modal identities but one geometry owner. Exercise every production class/key
through the shared primitive so touch drag/resize, corrupt/off-screen restoration, persistence,
and both sheet suspension breakpoints cannot silently diverge by caller identity.
*/
it.each(FN_8606_WINDOW_IDENTITIES)("keeps %s touch-moveable, resizable, clamped, and persisted", (_component, windowKey) => {
const geometryKey = `floating-window:${windowKey}`;
localStorage.setItem(geometryKey, JSON.stringify({
size: { width: 99999, height: 99999 },
position: { x: 99999, y: -99999 },
}));
const { unmount } = render(
<FloatingWindow
windowKey={windowKey}
title={windowKey}
ariaLabel={`${windowKey} dialog`}
onClose={() => {}}
hideHeader
dragHandleSelector=".migration-drag-handle"
className={`floating-window--${windowKey}`}
defaultSize={{ width: 500, height: 400 }}
minSize={{ width: 360, height: 280 }}
persistGeometryKey={geometryKey}
suspendGeometryPersistenceOnMobile
suspendGeometryPersistenceOnShortViewport
>
<div className="migration-drag-handle">Drag {windowKey}</div>
</FloatingWindow>,
);
const panel = expectFloatingWindowStructure(windowKey);
expect(screen.getByTestId(`floating-window-overlay-${windowKey}`)).toHaveAttribute("aria-label", `${windowKey} dialog`);
expect(Number.parseInt(panel.style.left, 10)).toBeGreaterThanOrEqual(16);
expect(Number.parseInt(panel.style.top, 10)).toBeGreaterThanOrEqual(16);
dragWithTouch(screen.getByText(`Drag ${windowKey}`));
resizeWithTouch(screen.getByTestId("floating-window-resize-se"));
const persisted = JSON.parse(localStorage.getItem(geometryKey) ?? "{}");
expect(persisted.position.x).toBeGreaterThanOrEqual(16);
expect(persisted.position.y).toBeGreaterThanOrEqual(16);
expect(persisted.size.width).toBeLessThanOrEqual(window.innerWidth - 32);
expect(persisted.size.height).toBeLessThanOrEqual(window.innerHeight - 32);
unmount();
});
it.each(FN_8606_WINDOW_IDENTITIES)("rejects corrupt persisted geometry for %s", (_component, windowKey) => {
const geometryKey = `floating-window:${windowKey}`;
localStorage.setItem(geometryKey, "not-json");
render(
<FloatingWindow
windowKey={windowKey}
title={windowKey}
onClose={() => {}}
defaultSize={{ width: 500, height: 400 }}
persistGeometryKey={geometryKey}
>
<div>corrupt geometry fallback</div>
</FloatingWindow>,
);
const panel = screen.getByTestId(`floating-window-${windowKey}`);
expect(Number.parseInt(panel.style.width, 10)).toBe(500);
});
it.each(FN_8606_WINDOW_IDENTITIES)("wires %s to its accessible shared-window identity", (component, windowKey) => {
const source = readAppFile(`components/${component}`);
expect(source).toContain(`windowKey=\"${windowKey}\"`);
expect(source).toContain(`className=\"floating-window--${windowKey}\"`);
expect(source).toContain(`persistGeometryKey=\"floating-window:${windowKey}\"`);
expect(source).toContain("ariaLabel=");
expect(source).toContain("suspendGeometryPersistenceOnMobile");
expect(source).toContain("suspendGeometryPersistenceOnShortViewport");
});
it.each(["phone", "short viewport"] as const)("suspends all FN-8606 geometry keys in %s sheet mode", (mode) => {
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
matches: mode === "phone" ? query === "(max-width: 767.98px)" : query === "(max-height: 480px)",
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})));
for (const [, windowKey] of FN_8606_WINDOW_IDENTITIES) {
const geometryKey = `floating-window:${windowKey}`;
const { unmount } = render(
<FloatingWindow
windowKey={windowKey}
title={windowKey}
ariaLabel={`${windowKey} dialog`}
onClose={() => {}}
hideHeader
dragHandleSelector=".migration-drag-handle"
className={`floating-window--${windowKey}`}
persistGeometryKey={geometryKey}
suspendGeometryPersistenceOnMobile
suspendGeometryPersistenceOnShortViewport
>
<div className="migration-drag-handle">Drag {windowKey}</div>
</FloatingWindow>,
);
expect(localStorage.getItem(geometryKey)).toBeNull();
expect(screen.getByTestId(`floating-window-${windowKey}`)).toBeInTheDocument();
expect(screen.queryByTestId("floating-window-resize-se")).not.toBeInTheDocument();
unmount();
}
const sheetBlock = mediaBlockFor(floatingWindowCss, "(max-width: 767.98px), (max-height: 480px)");
for (const [, windowKey] of FN_8606_WINDOW_IDENTITIES) {
expect(sheetBlock).toContain(`.floating-window--${windowKey}`);
}
});
it("makes only the mobile chat floating window full-screen", () => {
const mobileBlock = floatingWindowCss.match(/@media\s*\(max-width:\s*767\.98px\)\s*\{[\s\S]*?\.floating-window--chat \.chat-view\s*\{[\s\S]*?\n\}/)?.[0];
const mobileBlock = floatingWindowCss.match(/@media\s*\(max-width:\s*767\.98px\),\s*\(max-height:\s*480px\)\s*\{[\s\S]*?\.floating-window--chat \.chat-view\s*\{[\s\S]*?\n\}/)?.[0];
expect(mobileBlock).toContain(".floating-window--chat");
expect(mobileBlock).toContain("width: 100vw !important;");

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { GitManagerModal } from "../GitManagerModal";
import { assertModalGeometryRecoveryAndSheetContracts, assertRenderedModalTouchGeometry } from "./floatingWindowMigration.test-helpers";
import type { Task } from "@fusion/core";
import { loadAllAppCss } from "../../test/cssFixture";
@@ -20,8 +21,9 @@ const mockUseMobileKeyboard = vi.fn(() => ({
vi.mock("../../hooks/useViewportMode", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
isFullScreenSheetViewport: () => false,
isShortViewport: () => false,
isFullScreenSheetViewport: () => window.matchMedia("(max-width: 767.98px)").matches,
isShortViewport: () => window.matchMedia("(max-height: 480px)").matches,
isTabletTouchViewport: () => false,
getViewportMode: () => mockUseViewportMode(),
isMobileViewport: () => mockUseViewportMode() === "mobile",
useViewportMode: () => mockUseViewportMode(),
@@ -381,14 +383,14 @@ describe("GitManagerModal", () => {
});
it("renders git-manager overlay class hook for mobile fullscreen CSS", async () => {
const { container } = render(
const { baseElement } = render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
await waitFor(() => {
expect(screen.getByText("Git Manager")).toBeInTheDocument();
});
expect(container.querySelector(".modal-overlay.git-manager-modal-overlay")).toBeTruthy();
expect(baseElement.querySelector(".floating-window--git-manager")).toBeTruthy();
});
it("keeps the sidebar-launched Git Manager overlay transparent and click-through like Files", () => {
@@ -412,14 +414,14 @@ describe("GitManagerModal", () => {
keyboardOpen: true,
});
const { container } = render(
const { baseElement } = render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
await waitFor(() => {
expect(screen.getByText("Git Manager")).toBeInTheDocument();
});
const modal = container.querySelector(".modal.gm-modal") as HTMLElement;
const modal = baseElement.querySelector(".modal.gm-modal") as HTMLElement;
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("240px");
expect(modal.style.getPropertyValue("--vv-height")).toBe("620px");
expect(modal.style.getPropertyValue("--vv-offset-top")).toBe("18px");
@@ -698,7 +700,7 @@ describe("GitManagerModal", () => {
});
});
it("keeps separate scroll containers for unstaged and staged file lists", async () => {
it("keeps separate scroll baseElements for unstaged and staged file lists", async () => {
(fetchFileChanges as any).mockResolvedValue([
...Array.from({ length: 30 }, (_, index) => ({
file: `src/unstaged-${index}.ts`,
@@ -2189,7 +2191,7 @@ describe("GitManagerModal", () => {
]);
const user = userEvent.setup();
const { container } = render(
const { baseElement } = render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
@@ -2200,7 +2202,7 @@ describe("GitManagerModal", () => {
});
await waitFor(() => {
const selectedOrigin = container.querySelector(".gm-remote-selector-item.selected");
const selectedOrigin = baseElement.querySelector(".gm-remote-selector-item.selected");
expect(selectedOrigin).toBeTruthy();
expect(selectedOrigin?.textContent ?? "").toContain("origin");
});
@@ -2211,7 +2213,7 @@ describe("GitManagerModal", () => {
await user.click(upstreamButton as HTMLElement);
await waitFor(() => {
const selectedUpstream = container.querySelector(".gm-remote-selector-item.selected");
const selectedUpstream = baseElement.querySelector(".gm-remote-selector-item.selected");
expect(selectedUpstream).toBeTruthy();
expect(selectedUpstream?.textContent ?? "").toContain("upstream");
});
@@ -3788,3 +3790,11 @@ describe("GitManagerModal", () => {
});
});
});
describe("GitManagerModal floating geometry", () => {
it("uses its production header for touch drag and resize", () => {
render(<GitManagerModal isOpen onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />);
assertRenderedModalTouchGeometry("git-manager", screen.getByTestId("floating-window-git-manager").querySelector(".modal-header") as HTMLElement);
assertModalGeometryRecoveryAndSheetContracts("git-manager", () => render(<GitManagerModal isOpen onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />));
});
});

View File

@@ -1,6 +1,7 @@
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { GroupTaskModal } from "../GroupTaskModal";
import { assertModalGeometryRecoveryAndSheetContracts, assertRenderedModalTouchGeometry } from "./floatingWindowMigration.test-helpers";
import { apiGetBranchGroup, apiPromoteBranchGroup, apiAbandonBranchGroup } from "../../api";
type SseSubscription = {
@@ -82,6 +83,14 @@ describe("GroupTaskModal", () => {
expect(onOpenMemberTask).toHaveBeenCalledWith("FN-1");
});
it("uses the production group header for touch drag and resize", async () => {
mockedGet.mockResolvedValue({ group: makeGroup() } as Awaited<ReturnType<typeof apiGetBranchGroup>>);
render(<GroupTaskModal isOpen onClose={vi.fn()} groupId="BG-1" onOpenMemberTask={vi.fn()} />);
await screen.findByText("feature/shared");
assertRenderedModalTouchGeometry("group-task", screen.getByRole("heading", { name: "Branch Group BG-1" }).closest(".modal-header") as HTMLElement);
assertModalGeometryRecoveryAndSheetContracts("group-task", () => render(<GroupTaskModal isOpen onClose={vi.fn()} groupId="BG-1" onOpenMemberTask={vi.fn()} />));
});
it("hides promote controls until complete", async () => {
mockedGet.mockResolvedValue({ group: makeGroup() } as Awaited<ReturnType<typeof apiGetBranchGroup>>);

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, act, within } from "@testing-library/react";
import { ModelOnboardingModal } from "../ModelOnboardingModal";
import { assertModalGeometryRecoveryAndSheetContracts, assertRenderedModalTouchGeometry } from "./floatingWindowMigration.test-helpers";
import type { AuthProvider } from "../../api";
import { clearAuthToken } from "../../auth";
import type { Task } from "@fusion/core";
@@ -225,8 +226,8 @@ async function navigateToFirstTaskStep() {
});
}
function getProviderOrderInSection(container: HTMLElement): string[] {
return Array.from(container.querySelectorAll<HTMLElement>("[data-testid^='onboarding-provider-card-']"))
function getProviderOrderInSection(baseElement: HTMLElement): string[] {
return Array.from(baseElement.querySelectorAll<HTMLElement>("[data-testid^='onboarding-provider-card-']"))
.map((card) => card.dataset.testid?.replace("onboarding-provider-card-", "") ?? "")
.filter(Boolean);
}
@@ -1425,14 +1426,14 @@ describe("ModelOnboardingModal", () => {
],
});
const { container } = render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
const { baseElement } = render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
await waitFor(() => {
expect(screen.getByText("Anthropic")).toBeTruthy();
});
// Verify the authenticated provider has the connected modifier class
const connectedCards = container.querySelectorAll(".onboarding-provider-card--connected");
const connectedCards = baseElement.querySelectorAll(".onboarding-provider-card--connected");
expect(connectedCards.length).toBe(1);
// Verify the connected card contains Anthropic
@@ -1440,7 +1441,7 @@ describe("ModelOnboardingModal", () => {
expect(anthropicCard?.textContent?.includes("Anthropic")).toBe(true);
// Verify non-authenticated provider does not have the modifier
const allCards = container.querySelectorAll(".onboarding-provider-card");
const allCards = baseElement.querySelectorAll(".onboarding-provider-card");
const connectedCardIds = Array.from(connectedCards).map((card) =>
card.querySelector(".onboarding-provider-card__name")?.textContent
);
@@ -5143,4 +5144,11 @@ describe("Custom providers disclosure", () => {
expect(mockFetchModels.mock.calls.length).toBeGreaterThan(modelCallsBefore);
});
});
it("uses its production header for touch drag and resize", async () => {
render(<ModelOnboardingModal isOpen onClose={vi.fn()} onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
await screen.findByTestId("floating-window-model-onboarding");
assertRenderedModalTouchGeometry("model-onboarding", screen.getByTestId("floating-window-model-onboarding").querySelector(".model-onboarding-header") as HTMLElement);
assertModalGeometryRecoveryAndSheetContracts("model-onboarding", () => render(<ModelOnboardingModal isOpen onClose={vi.fn()} onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />));
});
});

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { NodeDetailModal } from "../NodeDetailModal";
import { assertModalGeometryRecoveryAndSheetContracts, assertRenderedModalTouchGeometry } from "./floatingWindowMigration.test-helpers";
import type { DockerNodeConfig, ManagedDockerNodeInfo, NodeInfo, ProjectInfo } from "../../api";
vi.mock("lucide-react", () => ({
@@ -206,3 +207,11 @@ describe("NodeDetailModal docker section", () => {
expect(screen.getByText("Read-only")).toBeInTheDocument();
});
});
describe("NodeDetailModal floating geometry", () => {
it("uses its production header for touch drag and resize", () => {
render(<NodeDetailModal {...baseProps} />);
assertRenderedModalTouchGeometry("node-detail", screen.getByText("Node Details").closest(".modal-header") as HTMLElement);
assertModalGeometryRecoveryAndSheetContracts("node-detail", () => render(<NodeDetailModal {...baseProps} />));
});
});

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { PlanningModeModal } from "../PlanningModeModal";
import { assertModalGeometryRecoveryAndSheetContracts, assertRenderedModalTouchGeometry } from "./floatingWindowMigration.test-helpers";
import {
mockStartPlanningStreaming,
mockCreatePlanningDraft,
@@ -105,8 +106,9 @@ vi.mock("../../hooks/useConfirm", () => ({
vi.mock("../../hooks/useViewportMode", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
isFullScreenSheetViewport: () => false,
isShortViewport: () => false,
isFullScreenSheetViewport: () => window.matchMedia("(max-width: 767.98px)").matches,
isShortViewport: () => window.matchMedia("(max-height: 480px)").matches,
isTabletTouchViewport: () => false,
useViewportMode: () => mockUseViewportMode(),
getViewportMode: () => mockUseViewportMode(),
isMobileViewport: () => mockUseViewportMode() === "mobile",
@@ -185,6 +187,9 @@ describe("PlanningModeModal autosize", () => {
});
it("grows initial planning textarea and caps at max", async () => {
// This assertion owns the blank composer; do not let its draft debounce replace it with a
// session view while verifying the textarea's height contract.
mockCreatePlanningDraft.mockReturnValue(new Promise(() => {}));
render(<PlanningModeModal isOpen={true} onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={mockTasks} />);
const textarea = screen.getByPlaceholderText(/Build a user authentication/i) as HTMLTextAreaElement;
@@ -199,20 +204,21 @@ describe("PlanningModeModal autosize", () => {
},
});
await userEvent.type(textarea, "line 1\nline 2");
fireEvent.change(textarea, { target: { value: "line 1\nline 2" } });
await waitFor(() => {
expect(Number.parseInt(textarea.style.height, 10)).toBeGreaterThanOrEqual(120);
expect(Number.parseInt(textarea.style.height, 10)).toBeLessThanOrEqual(640);
const renderedTextarea = screen.getByPlaceholderText(/Build a user authentication/i) as HTMLTextAreaElement;
expect(Number.parseInt(renderedTextarea.style.height, 10)).toBeGreaterThanOrEqual(120);
expect(Number.parseInt(renderedTextarea.style.height, 10)).toBeLessThanOrEqual(640);
});
await userEvent.type(textarea, "\nline 3\nline 4\nline 5");
fireEvent.change(screen.getByPlaceholderText(/Build a user authentication/i), { target: { value: "line 1\nline 2\nline 3\nline 4\nline 5" } });
await waitFor(() => {
expect(textarea.style.height).toBe("500px");
expect((screen.getByPlaceholderText(/Build a user authentication/i) as HTMLTextAreaElement).style.height).toBe("500px");
});
await userEvent.type(textarea, "\nline 6\nline 7");
fireEvent.change(screen.getByPlaceholderText(/Build a user authentication/i), { target: { value: "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7" } });
await waitFor(() => {
expect(textarea.style.height).toBe("640px");
expect((screen.getByPlaceholderText(/Build a user authentication/i) as HTMLTextAreaElement).style.height).toBe("640px");
});
});
@@ -316,4 +322,10 @@ describe("PlanningModeModal autosize", () => {
expect(screen.getByRole("button", { name: "Proceed with plan" })).toBeInTheDocument();
expect(screen.queryByTestId("planning-create-retry")).toBeNull();
});
it("uses its production header for touch drag and resize", () => {
render(<PlanningModeModal isOpen onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={mockTasks} />);
assertRenderedModalTouchGeometry("planning-mode", screen.getByTestId("floating-window-planning-mode").querySelector(".modal-header") as HTMLElement);
assertModalGeometryRecoveryAndSheetContracts("planning-mode", () => render(<PlanningModeModal isOpen onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={mockTasks} />));
});
});

View File

@@ -3,6 +3,7 @@ import { act, render, screen, fireEvent, waitFor } from "@testing-library/react"
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { ScheduledTasksModal } from "../ScheduledTasksModal";
import { assertModalGeometryRecoveryAndSheetContracts, assertRenderedModalTouchGeometry } from "./floatingWindowMigration.test-helpers";
import type { Routine } from "@fusion/core";
vi.mock("lucide-react", () => ({
@@ -179,9 +180,9 @@ describe("ScheduledTasksModal", () => {
const initialTop = Number.parseFloat(panel.style.top);
act(() => {
fireEvent.pointerDown(header, { pointerId: 11, clientX: 120, clientY: 80 });
fireEvent.pointerMove(panel, { pointerId: 11, clientX: 220, clientY: 140 });
fireEvent.pointerUp(panel, { pointerId: 11, clientX: 220, clientY: 140 });
fireEvent.pointerDown(header, { pointerId: 11, pointerType: "touch", clientX: 120, clientY: 80 });
fireEvent.pointerMove(panel, { pointerId: 11, pointerType: "touch", clientX: 220, clientY: 140 });
fireEvent.pointerUp(panel, { pointerId: 11, pointerType: "touch", clientX: 220, clientY: 140 });
});
await waitFor(() => {
@@ -195,9 +196,9 @@ describe("ScheduledTasksModal", () => {
const heightAfterDrag = Number.parseFloat(panel.style.height);
act(() => {
fireEvent.pointerDown(resizeHandle, { pointerId: 12, clientX: 700, clientY: 600 });
fireEvent.pointerMove(resizeHandle, { pointerId: 12, clientX: 760, clientY: 650 });
fireEvent.pointerUp(resizeHandle, { pointerId: 12, clientX: 760, clientY: 650 });
fireEvent.pointerDown(resizeHandle, { pointerId: 12, pointerType: "touch", clientX: 700, clientY: 600 });
fireEvent.pointerMove(resizeHandle, { pointerId: 12, pointerType: "touch", clientX: 760, clientY: 650 });
fireEvent.pointerUp(resizeHandle, { pointerId: 12, pointerType: "touch", clientX: 760, clientY: 650 });
});
await waitFor(() => {
@@ -636,3 +637,11 @@ describe("ScheduledTasksModal", () => {
});
});
});
describe("ScheduledTasksModal floating geometry", () => {
it("uses its production header for touch drag and resize", () => {
render(<ScheduledTasksModal onClose={vi.fn()} addToast={vi.fn()} />);
assertRenderedModalTouchGeometry("automation", screen.getByText("Automations").closest(".automation-modal__drag-handle") as HTMLElement);
assertModalGeometryRecoveryAndSheetContracts("automation", () => render(<ScheduledTasksModal onClose={vi.fn()} addToast={vi.fn()} />));
});
});

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { ScriptsModal } from "../ScriptsModal";
import { assertModalGeometryRecoveryAndSheetContracts, assertRenderedModalTouchGeometry } from "./floatingWindowMigration.test-helpers";
import type { ScriptEntry } from "../../api";
const mockScripts: Record<string, string> = {
@@ -30,16 +31,14 @@ beforeEach(() => {
});
describe("ScriptsModal", () => {
it("has the 'open' class on the modal overlay when visible", async () => {
it("renders inside its FloatingWindow when visible", async () => {
vi.mocked(fetchScripts).mockResolvedValueOnce(mockScripts);
render(
<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} onRunScript={onRunScript} />
);
const overlay = screen.getByTestId("scripts-modal");
expect(overlay.classList.contains("modal-overlay")).toBe(true);
expect(overlay.classList.contains("open")).toBe(true);
expect(screen.getByTestId("floating-window-scripts")).toBeInTheDocument();
});
it("does not render when closed", () => {
@@ -412,3 +411,11 @@ describe("ScriptsModal", () => {
});
});
});
describe("ScriptsModal floating geometry", () => {
it("uses its production header for touch drag and resize", () => {
render(<ScriptsModal isOpen onClose={onClose} addToast={addToast} onRunScript={onRunScript} />);
assertRenderedModalTouchGeometry("scripts", screen.getByText("Scripts").closest(".modal-header") as HTMLElement);
assertModalGeometryRecoveryAndSheetContracts("scripts", () => render(<ScriptsModal isOpen onClose={onClose} addToast={addToast} onRunScript={onRunScript} />));
});
});

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { SettingsModal } from "../SettingsModal";
import { assertModalGeometryRecoveryAndSheetContracts } from "./floatingWindowMigration.test-helpers";
/*
FNXC:Settings 2026-07-07-00:00:
@@ -37,8 +38,9 @@ vi.mock("../../api", async (importOriginal) => {
vi.mock("../../hooks/useViewportMode", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
isFullScreenSheetViewport: () => false,
isShortViewport: () => false,
isFullScreenSheetViewport: () => window.matchMedia("(max-width: 767.98px)").matches,
isShortViewport: () => window.matchMedia("(max-height: 480px)").matches,
isTabletTouchViewport: () => false,
useViewportMode: (...args: unknown[]) => mockUseViewportMode(...args),
getViewportMode: (...args: unknown[]) => mockUseViewportMode(...args),
isMobileViewport: () => mockUseViewportMode() === "mobile",
@@ -104,7 +106,7 @@ describe("SettingsModal mobile embedded close button (FN-7627)", () => {
it("renders exactly one modal-close button in the standalone modal presentation and does not add the mobile-embedded control", async () => {
mockUseViewportMode.mockReturnValue("mobile");
const { container } = renderModal({ presentation: "modal" });
const { baseElement } = renderModal({ presentation: "modal" });
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
/*
@@ -113,9 +115,9 @@ describe("SettingsModal mobile embedded close button (FN-7627)", () => {
Both may be named "Close"; the invariant is a single header modal-close and no
mobile-embedded close control (that only appears when presentation is embedded).
*/
expect(container.querySelectorAll(".modal-close")).toHaveLength(1);
expect(container.querySelector(".settings-embedded-mobile-close")).toBeNull();
expect(container.querySelector(".modal-close")?.getAttribute("aria-label")).toMatch(/close/i);
expect(baseElement.querySelectorAll(".modal-close")).toHaveLength(1);
expect(baseElement.querySelector(".settings-embedded-mobile-close")).toBeNull();
expect(baseElement.querySelector(".modal-close")?.getAttribute("aria-label")).toMatch(/close/i);
});
it("keeps the task-definition input-language toggle reachable in Project Models on mobile", async () => {
@@ -142,3 +144,14 @@ describe("SettingsModal mobile embedded close button (FN-7627)", () => {
expect(onClose).toHaveBeenCalledTimes(1);
});
});
describe("SettingsModal floating geometry", () => {
it("renders the production settings modal in its shared floating window", async () => {
localStorage.clear();
renderModal({ presentation: "modal" });
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
expect(screen.getByTestId("floating-window-settings")).toBeInTheDocument();
expect(screen.getByTestId("floating-window-resize-se")).toBeInTheDocument();
assertModalGeometryRecoveryAndSheetContracts("settings", () => renderModal({ presentation: "modal" }));
});
});

View File

@@ -3,6 +3,7 @@ import { describe, expect, it, vi, afterEach } from "vitest";
import { MessageSquare, Repeat } from "lucide-react";
import type { WorkflowDefinition, WorkflowStepTemplate } from "@fusion/core";
import { WorkflowAddStepModal, type AddStepPaletteEntry } from "../WorkflowAddStepModal";
import { assertModalGeometryRecoveryAndSheetContracts, assertRenderedModalTouchGeometry } from "./floatingWindowMigration.test-helpers";
/*
FNXC:WorkflowSimpleView 2026-07-12-14:30:
@@ -60,6 +61,12 @@ describe("WorkflowAddStepModal", () => {
expect(screen.getByTestId("wf-add-step-tpl-tpl-1-optional-group")).toBeInTheDocument();
});
it("uses the real workflow dialog header for touch geometry", () => {
renderModal(false);
assertRenderedModalTouchGeometry("workflow-add-step", screen.getByRole("heading", { name: "Add a step" }).closest(".wf-add-step-header") as HTMLElement);
assertModalGeometryRecoveryAndSheetContracts("workflow-add-step", () => renderModal(false));
});
it("hides containers, fragments, and optional-group inserts for container-internal targets", () => {
renderModal(true);
expect(screen.getByTestId("wf-add-step-prompt-prompt")).toBeInTheDocument();

View File

@@ -0,0 +1,126 @@
import { cleanup, fireEvent, screen, type RenderResult } from "@testing-library/react";
import { expect, vi } from "vitest";
/**
* FNXC:ModalTouchGeometry 2026-07-26-13:42:
* Modal migrations share one pointer sequence so every window identity is checked against the
* same touch drag contract instead of accumulating subtly different synthetic gestures.
*/
export function expectFloatingWindowStructure(windowKey: string): HTMLElement {
const panel = screen.getByTestId(`floating-window-${windowKey}`);
expect(panel).toBeInTheDocument();
for (const direction of ["n", "s", "e", "w", "ne", "nw", "se", "sw"]) {
expect(screen.getByTestId(`floating-window-resize-${direction}`)).toBeInTheDocument();
}
return panel;
}
function prepareTouchCapture(target: HTMLElement): void {
Object.defineProperty(target, "setPointerCapture", { configurable: true, value: vi.fn() });
Object.defineProperty(target, "releasePointerCapture", { configurable: true, value: vi.fn() });
}
export function dragWithTouch(handle: HTMLElement, pointerId = 991): void {
prepareTouchCapture(handle);
fireEvent.pointerDown(handle, { pointerId, pointerType: "touch", clientX: 100, clientY: 100 });
fireEvent.pointerMove(handle, { pointerId, pointerType: "touch", clientX: 140, clientY: 140 });
fireEvent.pointerUp(handle, { pointerId, pointerType: "touch", clientX: 140, clientY: 140 });
}
export function resizeWithTouch(handle: HTMLElement, pointerId = 992): void {
prepareTouchCapture(handle);
fireEvent.pointerDown(handle, { pointerId, pointerType: "touch", clientX: 100, clientY: 100 });
fireEvent.pointerMove(handle, { pointerId, pointerType: "touch", clientX: 140, clientY: 140 });
fireEvent.pointerUp(handle, { pointerId, pointerType: "touch", clientX: 140, clientY: 140 });
}
/**
* FNXC:ModalTouchGeometry 2026-07-26-16:25:
* Real modal tests use this after rendering their production component. It intentionally receives
* the modal's actual header element so a renamed or missing delegated drag selector fails here,
* rather than being hidden by a synthetic FloatingWindow fixture.
*/
export function assertRenderedModalTouchGeometry(windowKey: string, dragHandle: HTMLElement): void {
const panel = expectFloatingWindowStructure(windowKey);
const initialLeft = Number.parseFloat(panel.style.left);
const initialWidth = Number.parseFloat(panel.style.width);
dragWithTouch(dragHandle);
resizeWithTouch(screen.getByTestId("floating-window-resize-se"));
expect(Number.parseFloat(panel.style.left)).not.toBe(initialLeft);
expect(Number.parseFloat(panel.style.width)).toBeGreaterThan(initialWidth);
const persisted = JSON.parse(localStorage.getItem(`floating-window:${windowKey}`) ?? "{}");
expect(persisted.position.x).toBeGreaterThanOrEqual(16);
expect(persisted.position.y).toBeGreaterThanOrEqual(16);
expect(persisted.size.width).toBeLessThanOrEqual(window.innerWidth - 32);
expect(persisted.size.height).toBeLessThanOrEqual(window.innerHeight - 32);
}
type ModalMount = () => RenderResult;
type SheetMode = "phone" | "short";
function setSheetViewport(mode: SheetMode): () => void {
const original = window.matchMedia;
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: vi.fn((query: string) => ({
matches: mode === "phone" ? query === "(max-width: 767.98px)" : query === "(max-height: 480px)",
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
return () => Object.defineProperty(window, "matchMedia", { configurable: true, value: original });
}
/**
* FNXC:ModalTouchGeometry 2026-07-26-18:10:
* FN-8606 requires production renders, not a FloatingWindow stand-in, to prove every migrated
* caller rejects corrupt geometry, clamps a geometry saved on a larger display, and avoids both
* reads/writes plus drag/resize chrome in phone and short-viewport sheets.
*/
export function assertModalGeometryRecoveryAndSheetContracts(windowKey: string, mount: ModalMount): void {
const geometryKey = `floating-window:${windowKey}`;
cleanup();
localStorage.setItem(geometryKey, "not-json");
let rendered = mount();
const corruptPanel = screen.getByTestId(`floating-window-${windowKey}`);
expect(Number.parseFloat(corruptPanel.style.width)).toBeGreaterThan(0);
expect(Number.parseFloat(corruptPanel.style.height)).toBeGreaterThan(0);
rendered.unmount();
localStorage.setItem(geometryKey, JSON.stringify({
size: { width: 99999, height: 99999 },
position: { x: 99999, y: -99999 },
}));
rendered = mount();
const restoredPanel = screen.getByTestId(`floating-window-${windowKey}`);
expect(Number.parseFloat(restoredPanel.style.left)).toBeGreaterThanOrEqual(16);
expect(Number.parseFloat(restoredPanel.style.top)).toBeGreaterThanOrEqual(16);
expect(Number.parseFloat(restoredPanel.style.width)).toBeLessThanOrEqual(window.innerWidth - 32);
expect(Number.parseFloat(restoredPanel.style.height)).toBeLessThanOrEqual(window.innerHeight - 32);
rendered.unmount();
for (const mode of ["phone", "short"] as const) {
localStorage.setItem(geometryKey, JSON.stringify({ size: { width: 99999, height: 99999 }, position: { x: 99999, y: -99999 } }));
const restoreMatchMedia = setSheetViewport(mode);
const getItem = vi.spyOn(Storage.prototype, "getItem");
const setItem = vi.spyOn(Storage.prototype, "setItem");
try {
rendered = mount();
expect(screen.getByTestId(`floating-window-${windowKey}`)).toBeInTheDocument();
expect(screen.queryByTestId("floating-window-resize-se")).not.toBeInTheDocument();
expect(getItem).not.toHaveBeenCalledWith(geometryKey);
expect(setItem).not.toHaveBeenCalledWith(geometryKey, expect.any(String));
rendered.unmount();
} finally {
getItem.mockRestore();
setItem.mockRestore();
restoreMatchMedia();
}
}
}