From a6885b73f2c7a19621e6eb12671f71c9fb2c5d31 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 26 Jul 2026 14:35:29 -0700 Subject: [PATCH] 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) --- .../fn-8606-floating-window-core-modals.md | 7 + docs/dashboard-guide.md | 4 + packages/dashboard/app/App.tsx | 8 +- .../app/components/ActivityLogModal.tsx | 30 ++- .../dashboard/app/components/AddNodeModal.tsx | 8 +- .../app/components/ChangesDiffModal.tsx | 35 ++-- .../app/components/ConnectNodeModal.tsx | 9 +- .../app/components/FloatingWindow.css | 71 ++++++- .../app/components/FloatingWindow.tsx | 27 ++- .../app/components/GitManagerModal.tsx | 30 ++- .../app/components/GroupTaskModal.tsx | 8 +- .../app/components/ModelOnboardingModal.tsx | 28 ++- .../app/components/NodeDetailModal.tsx | 9 +- .../app/components/PlanningModeModal.css | 1 - .../app/components/PlanningModeModal.tsx | 57 +++--- .../app/components/ScheduledTasksModal.tsx | 9 +- .../dashboard/app/components/ScriptsModal.css | 1 - .../dashboard/app/components/ScriptsModal.tsx | 32 +++- .../app/components/SettingsModal.css | 1 - .../app/components/SettingsModal.tsx | 58 +++--- .../app/components/WorkflowAddStepModal.css | 15 -- .../app/components/WorkflowAddStepModal.tsx | 46 +++-- .../__tests__/ActivityLogModal.test.tsx | 33 ++-- .../__tests__/AddNodeModal.test.tsx | 12 +- .../__tests__/ChangesDiffModal.test.tsx | 78 ++++---- .../__tests__/ConnectNodeModal.test.tsx | 7 + .../__tests__/FloatingWindow.test.tsx | 173 +++++++++++++++++- .../__tests__/GitManagerModal.test.tsx | 30 ++- .../__tests__/GroupTaskModal.test.tsx | 9 + .../__tests__/ModelOnboardingModal.test.tsx | 18 +- .../__tests__/NodeDetailModal.test.tsx | 9 + .../PlanningModeModal.autosize.test.tsx | 30 ++- .../__tests__/ScheduledTasksModal.test.tsx | 21 ++- .../__tests__/ScriptsModal.test.tsx | 15 +- .../SettingsModal.mobileClose.test.tsx | 25 ++- .../__tests__/WorkflowAddStepModal.test.tsx | 7 + .../floatingWindowMigration.test-helpers.ts | 126 +++++++++++++ 37 files changed, 828 insertions(+), 259 deletions(-) create mode 100644 .changeset/fn-8606-floating-window-core-modals.md create mode 100644 packages/dashboard/app/components/__tests__/floatingWindowMigration.test-helpers.ts diff --git a/.changeset/fn-8606-floating-window-core-modals.md b/.changeset/fn-8606-floating-window-core-modals.md new file mode 100644 index 0000000000..d4276a249c --- /dev/null +++ b/.changeset/fn-8606-floating-window-core-modals.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index c02c37f59a..423ba6ae49 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. + + +Core/workflow FloatingWindow modals use `persistGeometryKey="floating-window:"`: `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. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index ac55cb3c1e..e6e9f45899 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -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 }} /* diff --git a/packages/dashboard/app/components/ActivityLogModal.tsx b/packages/dashboard/app/components/ActivityLogModal.tsx index 283decceb0..42fe85c5a9 100644 --- a/packages/dashboard/app/components/ActivityLogModal.tsx +++ b/packages/dashboard/app/components/ActivityLogModal.tsx @@ -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 ( -
{ - 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. + */ + {body} -
+ ); } diff --git a/packages/dashboard/app/components/AddNodeModal.tsx b/packages/dashboard/app/components/AddNodeModal.tsx index 56bd548045..78318e7c84 100644 --- a/packages/dashboard/app/components/AddNodeModal.tsx +++ b/packages/dashboard/app/components/AddNodeModal.tsx @@ -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 ( -
-
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. */ + +

{t("nodes.addNode", "Add Node")}

-
+ ); } diff --git a/packages/dashboard/app/components/ChangesDiffModal.tsx b/packages/dashboard/app/components/ChangesDiffModal.tsx index e9147c1a85..6f7d55403b 100644 --- a/packages/dashboard/app/components/ChangesDiffModal.tsx +++ b/packages/dashboard/app/components/ChangesDiffModal.tsx @@ -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(null); const [wordWrap, setWordWrap] = useState(true); - const modalRef = useRef(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 ( -
-
+ +
{/* Header */}
@@ -270,6 +281,6 @@ export function ChangesDiffModal({
-
+ ); } diff --git a/packages/dashboard/app/components/ConnectNodeModal.tsx b/packages/dashboard/app/components/ConnectNodeModal.tsx index b6c397f0b4..dcc44a9c90 100644 --- a/packages/dashboard/app/components/ConnectNodeModal.tsx +++ b/packages/dashboard/app/components/ConnectNodeModal.tsx @@ -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 ( -
+ /* 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. */ +
event.stopPropagation()} - role="dialog" - aria-modal="true" aria-label={t("nodes.modal.title", "Connect to Node")} >
@@ -274,6 +273,6 @@ export function ConnectNodeModal({ open, onClose, onConnected, addToast, onSubmi
-
+ ); } diff --git a/packages/dashboard/app/components/FloatingWindow.css b/packages/dashboard/app/components/FloatingWindow.css index a712364904..1751a118dc 100644 --- a/packages/dashboard/app/components/FloatingWindow.css +++ b/packages/dashboard/app/components/FloatingWindow.css @@ -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; } diff --git a/packages/dashboard/app/components/FloatingWindow.tsx b/packages/dashboard/app/components/FloatingWindow.tsx index 4871e06e96..1e441e65c3 100644 --- a/packages/dashboard/app/components/FloatingWindow.tsx +++ b/packages/dashboard/app/components/FloatingWindow.tsx @@ -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) => { - 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) => (
(null); const modalRef = useRef(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 ( -
+

@@ -1399,7 +1415,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj {gitBody}

-
+ ); } diff --git a/packages/dashboard/app/components/GroupTaskModal.tsx b/packages/dashboard/app/components/GroupTaskModal.tsx index 7392f66d94..4a54de28e8 100644 --- a/packages/dashboard/app/components/GroupTaskModal.tsx +++ b/packages/dashboard/app/components/GroupTaskModal.tsx @@ -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 ( -
-
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. */ + +

{t("groupTask.title", "Branch Group {{id}}", { id: groupId })}

-
+ ); } diff --git a/packages/dashboard/app/components/ModelOnboardingModal.tsx b/packages/dashboard/app/components/ModelOnboardingModal.tsx index d7ed7e06bd..676aee96b8 100644 --- a/packages/dashboard/app/components/ModelOnboardingModal.tsx +++ b/packages/dashboard/app/components/ModelOnboardingModal.tsx @@ -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(null); const apiKeySuccessTimers = useRef>>({}); const onboardingContentRef = useRef(null); - const modalRef = useRef(null); const pollIntervalRef = useRef | null>(null); const agentErrorRef = useRef(null); const [loginOutcomes, setLoginOutcomes] = useState>({}); @@ -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 ( -
-
+
{/* Header */}

@@ -3589,6 +3597,6 @@ export function ModelOnboardingModal({ )} -

+ ); } diff --git a/packages/dashboard/app/components/NodeDetailModal.tsx b/packages/dashboard/app/components/NodeDetailModal.tsx index 735ddff700..96a51d42f5 100644 --- a/packages/dashboard/app/components/NodeDetailModal.tsx +++ b/packages/dashboard/app/components/NodeDetailModal.tsx @@ -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 ( -
+ /* 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. */ +
event.stopPropagation()} - role="dialog" - aria-modal="true" aria-label={t("nodes.modalAriaLabel", "Node details for {{name}}", { name: node.name })} >
@@ -891,6 +890,6 @@ export function NodeDetailModal({ addToast={addToast} /> )} -
+ ); } diff --git a/packages/dashboard/app/components/PlanningModeModal.css b/packages/dashboard/app/components/PlanningModeModal.css index 574769b637..74e6a8656e 100644 --- a/packages/dashboard/app/components/PlanningModeModal.css +++ b/packages/dashboard/app/components/PlanningModeModal.css @@ -46,7 +46,6 @@ min-height: 480px; max-height: calc(100dvh - var(--overlay-padding-top, 10vh) - 16px); overflow: hidden; - resize: both; } /* diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx index 9d934b565e..3431606a11 100644 --- a/packages/dashboard/app/components/PlanningModeModal.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.tsx @@ -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(resumeSessionId)); const [pendingDeleteId, setPendingDeleteId] = useState(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(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 ); - return ( -
) => { - overlayMouseDownOnSelfRef.current = e.target === e.currentTarget; - }} - onClick={isEmbedded ? undefined : (e: MouseEvent) => { - 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 ? ( +
+ {children} +
+ ) : ( + + {children} + + ); + + return ( +
{/* FNXC:PlanningMode 2026-06-22-00:00: @@ -4245,7 +4250,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
-
+ ); } diff --git a/packages/dashboard/app/components/ScheduledTasksModal.tsx b/packages/dashboard/app/components/ScheduledTasksModal.tsx index e844e88187..1cc97abbb2 100644 --- a/packages/dashboard/app/components/ScheduledTasksModal.tsx +++ b/packages/dashboard/app/components/ScheduledTasksModal.tsx @@ -560,14 +560,21 @@ export function ScheduledTasksModal({ onClose, addToast, projectId, presentation {/** diff --git a/packages/dashboard/app/components/ScriptsModal.css b/packages/dashboard/app/components/ScriptsModal.css index 2a5c15192f..fe3f1810a9 100644 --- a/packages/dashboard/app/components/ScriptsModal.css +++ b/packages/dashboard/app/components/ScriptsModal.css @@ -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; } diff --git a/packages/dashboard/app/components/ScriptsModal.tsx b/packages/dashboard/app/components/ScriptsModal.tsx index ab86d2478b..20c1bae9cc 100644 --- a/packages/dashboard/app/components/ScriptsModal.tsx +++ b/packages/dashboard/app/components/ScriptsModal.tsx @@ -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>({}); 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(null); const [nameError, setNameError] = useState(null); - const overlayDismissProps = useOverlayDismiss(onClose); const loadScripts = useCallback(async () => { try { @@ -176,13 +177,24 @@ export function ScriptsModal({ isOpen, onClose, addToast, projectId, onRunScript })); return ( -
-
+ /* 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. */ + +
{/* Header */}

@@ -490,6 +502,6 @@ export function ScriptsModal({ isOpen, onClose, addToast, projectId, onRunScript )}

-
+ ); } diff --git a/packages/dashboard/app/components/SettingsModal.css b/packages/dashboard/app/components/SettingsModal.css index c312f7efa1..b318311bfb 100644 --- a/packages/dashboard/app/components/SettingsModal.css +++ b/packages/dashboard/app/components/SettingsModal.css @@ -120,7 +120,6 @@ min-height: 480px; max-height: calc(100dvh - var(--overlay-padding-top, 10vh) - 16px); overflow: hidden; - resize: both; } /* diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index 7c6a6ee200..eb45ea709b 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -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(null); const settingsContentRef = useRef(null); const workflowLaneSaverRef = useRef(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({ 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 ? (
+ {children} +
+ ) : ( + 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} + + ); + + return ( +
@@ -5381,7 +5391,7 @@ export function SettingsModal({
)} -
+ ); } diff --git a/packages/dashboard/app/components/WorkflowAddStepModal.css b/packages/dashboard/app/components/WorkflowAddStepModal.css index 0bd2038a90..37af15cf6f 100644 --- a/packages/dashboard/app/components/WorkflowAddStepModal.css +++ b/packages/dashboard/app/components/WorkflowAddStepModal.css @@ -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; diff --git a/packages/dashboard/app/components/WorkflowAddStepModal.tsx b/packages/dashboard/app/components/WorkflowAddStepModal.tsx index 167342c753..4f64236e09 100644 --- a/packages/dashboard/app/components/WorkflowAddStepModal.tsx +++ b/packages/dashboard/app/components/WorkflowAddStepModal.tsx @@ -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 ( -
{ - 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. */ + -
e.stopPropagation()} - > +

{t("workflowNodes.addStepTitle", "Add a step")}

-
+
); } diff --git a/packages/dashboard/app/components/__tests__/ActivityLogModal.test.tsx b/packages/dashboard/app/components/__tests__/ActivityLogModal.test.tsx index 567842751e..042b9bdcb3 100644 --- a/packages/dashboard/app/components/__tests__/ActivityLogModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/ActivityLogModal.test.tsx @@ -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( { ); 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( { ); 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( { }); // 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( { // 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( { }); // 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(); + assertRenderedModalTouchGeometry("activity-log", screen.getByText("Activity Log").closest(".modal-header") as HTMLElement); + assertModalGeometryRecoveryAndSheetContracts("activity-log", () => render()); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/AddNodeModal.test.tsx b/packages/dashboard/app/components/__tests__/AddNodeModal.test.tsx index 8da61ce8de..e1d4879966 100644 --- a/packages/dashboard/app/components/__tests__/AddNodeModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/AddNodeModal.test.tsx @@ -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(); // 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(); + assertRenderedModalTouchGeometry("add-node", screen.getByTestId("floating-window-add-node").querySelector(".modal-header") as HTMLElement); + assertModalGeometryRecoveryAndSheetContracts("add-node", () => render()); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/ChangesDiffModal.test.tsx b/packages/dashboard/app/components/__tests__/ChangesDiffModal.test.tsx index a664b5a46e..740cdaff29 100644 --- a/packages/dashboard/app/components/__tests__/ChangesDiffModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChangesDiffModal.test.tsx @@ -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( , ); - 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( , ); // 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(); + const { baseElement } = render(); - 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( , ); // 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( , ); @@ -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( , ); - 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( , ); - 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( , ); - 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( { />, ); - 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( , ); - 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( , ); - 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(); + assertRenderedModalTouchGeometry("changes-diff", screen.getByTestId("floating-window-changes-diff").querySelector(".modal-header") as HTMLElement); + assertModalGeometryRecoveryAndSheetContracts("changes-diff", () => render()); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/ConnectNodeModal.test.tsx b/packages/dashboard/app/components/__tests__/ConnectNodeModal.test.tsx index ddf0a702b7..51159f8ec7 100644 --- a/packages/dashboard/app/components/__tests__/ConnectNodeModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/ConnectNodeModal.test.tsx @@ -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(); + assertRenderedModalTouchGeometry("connect-node", screen.getByText("Connect to Node").closest(".modal-header") as HTMLElement); + assertModalGeometryRecoveryAndSheetContracts("connect-node", () => render()); + }); + it("does not render when closed", () => { render(); diff --git a/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx b/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx index 0263206dce..5953b85b6a 100644 --- a/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx +++ b/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx @@ -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", () => { ); 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(` { 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( + {}} + persistGeometryKey={key} + suspendGeometryPersistenceOnMobile + suspendGeometryPersistenceOnShortViewport + defaultPosition={{ x: 80, y: 90 }} + > +
short sheet body
+
, + ); + + 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( + {}} + hideHeader + dragHandleSelector=".migration-drag-handle" + className={`floating-window--${windowKey}`} + defaultSize={{ width: 500, height: 400 }} + minSize={{ width: 360, height: 280 }} + persistGeometryKey={geometryKey} + suspendGeometryPersistenceOnMobile + suspendGeometryPersistenceOnShortViewport + > +
Drag {windowKey}
+
, + ); + + 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( + {}} + defaultSize={{ width: 500, height: 400 }} + persistGeometryKey={geometryKey} + > +
corrupt geometry fallback
+
, + ); + 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( + {}} + hideHeader + dragHandleSelector=".migration-drag-handle" + className={`floating-window--${windowKey}`} + persistGeometryKey={geometryKey} + suspendGeometryPersistenceOnMobile + suspendGeometryPersistenceOnShortViewport + > +
Drag {windowKey}
+
, + ); + 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;"); diff --git a/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx b/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx index 1f41cdab17..ad866094a7 100644 --- a/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx @@ -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( ); 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( ); 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( ); 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(); + assertRenderedModalTouchGeometry("git-manager", screen.getByTestId("floating-window-git-manager").querySelector(".modal-header") as HTMLElement); + assertModalGeometryRecoveryAndSheetContracts("git-manager", () => render()); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/GroupTaskModal.test.tsx b/packages/dashboard/app/components/__tests__/GroupTaskModal.test.tsx index 84404ac652..db2224f0a7 100644 --- a/packages/dashboard/app/components/__tests__/GroupTaskModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/GroupTaskModal.test.tsx @@ -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>); + render(); + 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()); + }); + it("hides promote controls until complete", async () => { mockedGet.mockResolvedValue({ group: makeGroup() } as Awaited>); diff --git a/packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx b/packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx index 19fd9e35c9..46a9e89726 100644 --- a/packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx @@ -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("[data-testid^='onboarding-provider-card-']")) +function getProviderOrderInSection(baseElement: HTMLElement): string[] { + return Array.from(baseElement.querySelectorAll("[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(); + const { baseElement } = render(); 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(); + 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()); + }); }); diff --git a/packages/dashboard/app/components/__tests__/NodeDetailModal.test.tsx b/packages/dashboard/app/components/__tests__/NodeDetailModal.test.tsx index 9dd4700f77..058b9cc8b4 100644 --- a/packages/dashboard/app/components/__tests__/NodeDetailModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/NodeDetailModal.test.tsx @@ -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(); + assertRenderedModalTouchGeometry("node-detail", screen.getByText("Node Details").closest(".modal-header") as HTMLElement); + assertModalGeometryRecoveryAndSheetContracts("node-detail", () => render()); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/PlanningModeModal.autosize.test.tsx b/packages/dashboard/app/components/__tests__/PlanningModeModal.autosize.test.tsx index 9b906f6bc7..706b1ec8dc 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.autosize.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.autosize.test.tsx @@ -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(); 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(); + assertRenderedModalTouchGeometry("planning-mode", screen.getByTestId("floating-window-planning-mode").querySelector(".modal-header") as HTMLElement); + assertModalGeometryRecoveryAndSheetContracts("planning-mode", () => render()); + }); }); diff --git a/packages/dashboard/app/components/__tests__/ScheduledTasksModal.test.tsx b/packages/dashboard/app/components/__tests__/ScheduledTasksModal.test.tsx index 66f8ae4892..098ab5316c 100644 --- a/packages/dashboard/app/components/__tests__/ScheduledTasksModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/ScheduledTasksModal.test.tsx @@ -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(); + assertRenderedModalTouchGeometry("automation", screen.getByText("Automations").closest(".automation-modal__drag-handle") as HTMLElement); + assertModalGeometryRecoveryAndSheetContracts("automation", () => render()); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/ScriptsModal.test.tsx b/packages/dashboard/app/components/__tests__/ScriptsModal.test.tsx index bbb45fef77..87ff29c149 100644 --- a/packages/dashboard/app/components/__tests__/ScriptsModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/ScriptsModal.test.tsx @@ -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 = { @@ -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( ); - 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(); + assertRenderedModalTouchGeometry("scripts", screen.getByText("Scripts").closest(".modal-header") as HTMLElement); + assertModalGeometryRecoveryAndSheetContracts("scripts", () => render()); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.mobileClose.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.mobileClose.test.tsx index 08cf25a9c8..3055d64fd9 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.mobileClose.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.mobileClose.test.tsx @@ -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" })); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/WorkflowAddStepModal.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowAddStepModal.test.tsx index 79ae5ae48a..a0704954d4 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowAddStepModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkflowAddStepModal.test.tsx @@ -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(); diff --git a/packages/dashboard/app/components/__tests__/floatingWindowMigration.test-helpers.ts b/packages/dashboard/app/components/__tests__/floatingWindowMigration.test-helpers.ts new file mode 100644 index 0000000000..7b29ed2035 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/floatingWindowMigration.test-helpers.ts @@ -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(); + } + } +}