FN-8606: migrate core workflow modals to shared floating windows
Migrate dashboard dialogs to the shared movable and resizable FloatingWindow contract. - Move core, workflow, Git, planning, and automation modal surfaces to stable floating-window identities with persisted geometry. - Suspend geometry and floating controls for phone and short-viewport sheets, including Quick Chat. - Add accessibility wiring, tablet touch targets, migration tests, and operator documentation. Files changed: .changeset/fn-8606-floating-window-core-modals.md | 7 + docs/dashboard-guide.md | 4 + packages/dashboard/app/App.tsx | 8 +- .../dashboard/app/components/ActivityLogModal.tsx | 30 ++-- packages/dashboard/app/components/AddNodeModal.tsx | 8 +- .../dashboard/app/components/ChangesDiffModal.tsx | 35 +++-- .../dashboard/app/components/ConnectNodeModal.tsx | 9 +- .../dashboard/app/components/FloatingWindow.css | 71 ++++++++- .../dashboard/app/components/FloatingWindow.tsx | 27 +++- .../dashboard/app/components/GitManagerModal.tsx | 30 +++- .../dashboard/app/components/GroupTaskModal.tsx | 8 +- .../app/components/ModelOnboardingModal.tsx | 28 ++-- .../dashboard/app/components/NodeDetailModal.tsx | 9 +- .../dashboard/app/components/PlanningModeModal.css | 1 - .../dashboard/app/components/PlanningModeModal.tsx | 57 +++---- .../app/components/ScheduledTasksModal.tsx | 9 +- packages/dashboard/app/components/ScriptsModal.css | 1 - packages/dashboard/app/components/ScriptsModal.tsx | 32 ++-- .../dashboard/app/components/SettingsModal.css | 1 - .../dashboard/app/components/SettingsModal.tsx | 58 ++++--- .../app/components/WorkflowAddStepModal.css | 15 -- .../app/components/WorkflowAddStepModal.tsx | 46 +++--- .../components/__tests__/ActivityLogModal.test.tsx | 33 ++-- .../app/components/__tests__/AddNodeModal.test.tsx | 12 +- .../components/__tests__/ChangesDiffModal.test.tsx | 78 +++++----- .../components/__tests__/ConnectNodeModal.test.tsx | 7 + .../components/__tests__/FloatingWindow.test.tsx | 173 ++++++++++++++++++++- .../components/__tests__/GitManagerModal.test.tsx | 30 ++-- .../components/__tests__/GroupTaskModal.test.tsx | 9 ++ .../__tests__/ModelOnboardingModal.test.tsx | 18 ++- .../components/__tests__/NodeDetailModal.test.tsx | 9 ++ .../__tests__/PlanningModeModal.autosize.test.tsx | 30 ++-- .../__tests__/ScheduledTasksModal.test.tsx | 21 ++- .../app/components/__tests__/ScriptsModal.test.tsx | 15 +- .../__tests__/SettingsModal.mobileClose.test.tsx | 25 ++- .../__tests__/WorkflowAddStepModal.test.tsx | 7 + .../floatingWindowMigration.test-helpers.ts | 126 +++++++++++++++ 37 files changed, 828 insertions(+), 259 deletions(-) Fusion-Task-Id: FN-8606 Fusion-Task-Lineage: dab0df2d-73f4-4b0f-bec3-a45016310c91 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8606-floating-window-core-modals.md
Normal file
7
.changeset/fn-8606-floating-window-core-modals.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Make core, workflow, and Git dashboard dialogs draggable and resizable on tablets.
|
||||
category: feature
|
||||
dev: Migrates 13 modal surfaces to FloatingWindow keys with persisted clamped geometry and removes legacy resize-grip paths.
|
||||
@@ -96,6 +96,10 @@ Task Detail and New Task remain resizable on known touch tablets, including a 76
|
||||
|
||||
Use `FloatingWindow` for a moveable and resizable dashboard surface rather than adding per-modal pointer code. On known tablet touch viewports it uses `isTabletTouchViewport`, applies `data-resize-hit-target="true"` to the drag handle and all eight edge/corner handles, and expands only their hit areas to the shared 44px target without thickening painted borders or covering content/footer controls. Never gate these controls on bare `(pointer: coarse)`: desktop hybrids keep desktop geometry. Phone full-screen sheets are strictly **below 768px** (`max-width: 767.98px`); a 768px viewport is tablet-class, so JS geometry and CSS must preserve active targets there.
|
||||
|
||||
<!-- FNXC:ModalTouchGeometryDocs 2026-07-26-13:50: Core/workflow modal migrations use stable window keys so one shared primitive owns drag, resize, clamping, stacking, and persisted geometry. -->
|
||||
|
||||
Core/workflow FloatingWindow modals use `persistGeometryKey="floating-window:<windowKey>"`: `automation` (Scheduled Tasks), `settings`, `git-manager`, `planning-mode`, `changes-diff`, `model-onboarding`, `activity-log`, `scripts`, `add-node`, `connect-node`, `node-detail`, `workflow-add-step`, and `group-task`. The former size-only `fusion:settings-modal-size`, `fusion:git-modal-size`, `fusion:planning-modal-size`, `fusion:changes-diff-modal-size`, and `fusion:model-onboarding-modal-size` keys are superseded by their matching complete geometry records. All of these windows suspend reading and writing geometry on phone and short (`max-height: 480px`) sheet viewports, so a desktop position never leaks into the sheet and a sheet never overwrites the desktop choice.
|
||||
|
||||
## Mobile/PWA app icons
|
||||
|
||||
The installed mobile/PWA home-screen icons are generated from `packages/dashboard/app/public/logo.svg` by the desktop icon generator. When the Fusion brand mark changes, run `pnpm --filter @fusion/desktop generate:icons` so `packages/dashboard/app/public/icons/icon-192.png` and `packages/dashboard/app/public/icons/icon-512.png` stay aligned with the canonical logo. Also bump `CACHE_NAME` in `packages/dashboard/app/public/sw.js` whenever those icon assets change so installed PWAs refresh the cached launcher images.
|
||||
|
||||
@@ -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 }}
|
||||
/*
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useEmbeddedPresentation, type ModalPresentation } from "../hooks/useEmb
|
||||
import type { Task, ProjectInfo } from "@fusion/core";
|
||||
import { linkifyFilePaths } from "../utils/filePathLinkify";
|
||||
import { getRelativeTimeBucket } from "../utils/relativeTimeAgo";
|
||||
import { FloatingWindow } from "./FloatingWindow";
|
||||
|
||||
interface ActivityLogModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -477,16 +478,27 @@ export function ActivityLogModal({
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay open"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
data-testid="activity-log-modal-overlay"
|
||||
/*
|
||||
FNXC:ModalTouchGeometry 2026-07-26-13:20:
|
||||
Activity Log keeps its embedded return above this branch. The modal branch delegates backdrop
|
||||
dismissal, drag, resize, clamping, and persistence to the shared FloatingWindow contract.
|
||||
*/
|
||||
<FloatingWindow
|
||||
windowKey="activity-log"
|
||||
title={t("activityLog.title", "Activity Log")}
|
||||
ariaLabel={`${t("activityLog.title", "Activity Log")} dialog`}
|
||||
onClose={onClose}
|
||||
hideHeader
|
||||
dragHandleSelector=".activity-log-header"
|
||||
className="floating-window--activity-log"
|
||||
defaultSize={{ width: 720, height: 560 }}
|
||||
minSize={{ width: 360, height: 280 }}
|
||||
persistGeometryKey="floating-window:activity-log"
|
||||
suspendGeometryPersistenceOnMobile
|
||||
suspendGeometryPersistenceOnShortViewport
|
||||
closeOnOutsidePointerDown
|
||||
>
|
||||
{body}
|
||||
</div>
|
||||
</FloatingWindow>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { ToastType } from "../hooks/useToast";
|
||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||
import "./AddNodeModal.css";
|
||||
|
||||
import { FloatingWindow } from "./FloatingWindow";
|
||||
export interface AddNodeInput {
|
||||
name: string;
|
||||
type: "local" | "remote";
|
||||
@@ -255,8 +256,9 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, onDiscoverRemoteProjec
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={closeModal}>
|
||||
<div className="modal modal-md add-node-modal" onClick={(event) => event.stopPropagation()} role="dialog" aria-modal="true" aria-label={t("nodes.addNode", "Add Node")}>
|
||||
/* FNXC:ModalTouchGeometry 2026-07-26-13:15: Shared FloatingWindow owns this modal's touch drag, resize, clamping, and persistence while phone and short viewports retain their sheet behavior. */
|
||||
<FloatingWindow windowKey="add-node" title={t("nodes.addNode", "Add Node")} ariaLabel={`${t("nodes.addNode", "Add Node")} dialog`} onClose={closeModal} hideHeader dragHandleSelector=".modal-header" className="floating-window--add-node" defaultSize={{ width: 720, height: 560 }} minSize={{ width: 360, height: 280 }} persistGeometryKey="floating-window:add-node" suspendGeometryPersistenceOnMobile suspendGeometryPersistenceOnShortViewport closeOnOutsidePointerDown>
|
||||
<div className="modal modal-md add-node-modal" aria-label={t("nodes.addNode", "Add Node")}>
|
||||
<div className="modal-header">
|
||||
<h3>{t("nodes.addNode", "Add Node")}</h3>
|
||||
<button className="modal-close" onClick={closeModal} disabled={isSubmitting} aria-label={t("nodes.closeNodeModal", "Close add node modal")}>
|
||||
@@ -468,6 +470,6 @@ export function AddNodeModal({ isOpen, onClose, onSubmit, onDiscoverRemoteProjec
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FloatingWindow>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||
import { FloatingWindow } from "./FloatingWindow";
|
||||
import { useModalDismissPreference } from "../hooks/useOverlayDismiss";
|
||||
import {
|
||||
X,
|
||||
FileCode,
|
||||
@@ -68,11 +68,10 @@ export function ChangesDiffModal({
|
||||
onRefresh,
|
||||
}: ChangesDiffModalProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const dismissOnOutsidePointerDown = useModalDismissPreference();
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
const [wordWrap, setWordWrap] = useState(true);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
useModalResizePersist(modalRef, isOpen, "fusion:changes-diff-modal-size");
|
||||
const overlayDismissProps = useOverlayDismiss(onClose);
|
||||
// FNXC:ModalTouchGeometry 2026-07-26-13:30: FloatingWindow supersedes the legacy size-only grip and persists the complete clamped geometry under its stable window key.
|
||||
|
||||
// Auto-select first file when files change
|
||||
useEffect(() => {
|
||||
@@ -121,11 +120,23 @@ export function ChangesDiffModal({
|
||||
const isDone = column === "done";
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" {...overlayDismissProps} role="dialog" aria-modal="true">
|
||||
<div
|
||||
className="modal changes-diff-modal"
|
||||
ref={modalRef}
|
||||
>
|
||||
<FloatingWindow
|
||||
windowKey="changes-diff"
|
||||
title={t("changes.title", "Changes")}
|
||||
ariaLabel={`${t("changes.title", "Changes")} dialog`}
|
||||
onClose={onClose}
|
||||
hideHeader
|
||||
dragHandleSelector=".changes-diff-modal-header"
|
||||
className="floating-window--changes-diff"
|
||||
defaultSize={{ width: 960, height: 640 }}
|
||||
minSize={{ width: 360, height: 280 }}
|
||||
persistGeometryKey="floating-window:changes-diff"
|
||||
suspendGeometryPersistenceOnMobile
|
||||
suspendGeometryPersistenceOnShortViewport
|
||||
/* FNXC:ModalTouchGeometry 2026-07-26-16:10: Keep Changes' historical preference-gated backdrop dismissal while FloatingWindow ignores active drag and resize gestures. */
|
||||
closeOnOutsidePointerDown={dismissOnOutsidePointerDown}
|
||||
>
|
||||
<div className="modal changes-diff-modal">
|
||||
{/* Header */}
|
||||
<div className="modal-header changes-diff-modal-header">
|
||||
<div className="changes-diff-header-title">
|
||||
@@ -270,6 +281,6 @@ export function ChangesDiffModal({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FloatingWindow>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { NodeCreateInput, NodeInfo } from "../api";
|
||||
|
||||
import { FloatingWindow } from "./FloatingWindow";
|
||||
export interface ConnectNodeInput {
|
||||
name: string;
|
||||
url: string;
|
||||
@@ -166,12 +167,10 @@ export function ConnectNodeModal({ open, onClose, onConnected, addToast, onSubmi
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={onClose}>
|
||||
/* FNXC:ModalTouchGeometry 2026-07-26-13:15: Shared FloatingWindow owns this modal's touch drag, resize, clamping, and persistence while phone and short viewports retain their sheet behavior. */
|
||||
<FloatingWindow windowKey="connect-node" title={t("nodes.modal.title", "Connect to Node")} ariaLabel={`${t("nodes.modal.title", "Connect to Node")} dialog`} onClose={onClose} hideHeader dragHandleSelector=".modal-header" className="floating-window--connect-node" defaultSize={{ width: 720, height: 560 }} minSize={{ width: 360, height: 280 }} persistGeometryKey="floating-window:connect-node" suspendGeometryPersistenceOnMobile suspendGeometryPersistenceOnShortViewport closeOnOutsidePointerDown>
|
||||
<div
|
||||
className="modal modal-md connect-node-modal"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("nodes.modal.title", "Connect to Node")}
|
||||
>
|
||||
<div className="modal-header">
|
||||
@@ -274,6 +273,6 @@ export function ConnectNodeModal({ open, onClose, onConnected, addToast, onSubmi
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FloatingWindow>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -72,6 +72,12 @@ export interface FloatingWindowProps {
|
||||
layer?: "utility" | "task-detail";
|
||||
// FNXC:FloatingWindow 2026-07-11-11:30: accessible name for the dialog overlay so headerless windows (e.g. artifact viewers with their own header chrome) stay queryable/announcable by label.
|
||||
ariaLabel?: string;
|
||||
/*
|
||||
FNXC:ModalTouchGeometry 2026-07-26-14:09:
|
||||
Headerless migrated dialogs may own a step-dependent title inside custom chrome. Forward its
|
||||
id to the shared dialog so screen readers retain that live name instead of a stale seed title.
|
||||
*/
|
||||
ariaLabelledBy?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_WIDTH = 720;
|
||||
@@ -196,6 +202,7 @@ export function FloatingWindow({
|
||||
hidden = false,
|
||||
layer = "utility",
|
||||
ariaLabel,
|
||||
ariaLabelledBy,
|
||||
}: FloatingWindowProps) {
|
||||
const resolvedMinSize: FloatingWindowSize = minSize ?? { width: DEFAULT_MIN_WIDTH, height: DEFAULT_MIN_HEIGHT };
|
||||
const viewportMode = useViewportMode();
|
||||
@@ -275,7 +282,14 @@ export function FloatingWindow({
|
||||
|
||||
const handleDragPointerDown = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if ((event.target as HTMLElement).closest("button")) return;
|
||||
/*
|
||||
FNXC:ModalTouchGeometry 2026-07-26-13:35:
|
||||
FN-8606 sheet callers must expose neither movable geometry nor resize chrome on phone and
|
||||
short viewports. Do not begin a delegated or built-in header drag while persistence is
|
||||
suspended; CSS alone cannot prevent the panel-level pointer handler from receiving touches.
|
||||
*/
|
||||
/* FNXC:ModalTouchGeometry 2026-07-26-14:20: Delegated headers commonly contain links (for example Settings' GitHub/Discord actions), which must retain native activation rather than starting a window drag. */
|
||||
if (geometryPersistenceSuspended || (event.target as HTMLElement).closest("button, a, input, select, textarea, [contenteditable=\"true\"], [role=\"button\"], [role=\"link\"]")) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
/*
|
||||
@@ -336,7 +350,7 @@ export function FloatingWindow({
|
||||
captureTarget.addEventListener("pointerup", handlePointerUp);
|
||||
captureTarget.addEventListener("pointercancel", handlePointerUp);
|
||||
},
|
||||
[bringToFront, position, size]
|
||||
[bringToFront, geometryPersistenceSuspended, position, size]
|
||||
);
|
||||
|
||||
const handlePanelPointerDown = useCallback(
|
||||
@@ -535,6 +549,7 @@ export function FloatingWindow({
|
||||
aria-modal="false"
|
||||
aria-hidden={hidden || undefined}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
data-testid={`floating-window-overlay-${windowKey}`}
|
||||
// FNXC:FloatingWindow 2026-06-22-23:00: The z-index MUST live on the position:fixed overlay (which creates a stacking context), not the panel. A panel z-index is trapped inside the overlay's context and loses to page elements that are stacking contexts in body's context (e.g. the right dock at position:absolute z-index:20). With z on the overlay, the whole window sits at the shared floating band in body's stacking context and reliably paints above page content + tap-to-front reorders correctly.
|
||||
style={{ zIndex }}
|
||||
@@ -548,7 +563,13 @@ export function FloatingWindow({
|
||||
onPointerDown={handlePanelPointerDown}
|
||||
onFocusCapture={bringToFront}
|
||||
>
|
||||
{RESIZE_DIRECTIONS.map((direction) => (
|
||||
{/*
|
||||
FNXC:ModalTouchGeometry 2026-07-26-16:54:
|
||||
Phone and short-viewport callers opt into a full-screen sheet. Do not merely hide resize
|
||||
handles with CSS there: removing them from the accessibility tree ensures those sheets
|
||||
expose no floating-window affordance or touch gesture surface.
|
||||
*/}
|
||||
{!geometryPersistenceSuspended && RESIZE_DIRECTIONS.map((direction) => (
|
||||
<div
|
||||
key={direction}
|
||||
className={`floating-window__resize-handle floating-window__resize-handle--${direction}`}
|
||||
|
||||
@@ -6,11 +6,11 @@ import { getErrorMessage } from "@fusion/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useConfirm } from "../hooks/useConfirm";
|
||||
import { getPathBasename } from "../utils/pathDisplay";
|
||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||
import { FloatingWindow } from "./FloatingWindow";
|
||||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||
import { useEmbeddedPresentation, type ModalPresentation } from "../hooks/useEmbeddedPresentation";
|
||||
import { useModalDismissPreference } from "../hooks/useOverlayDismiss";
|
||||
import { useViewportMode } from "../hooks/useViewportMode";
|
||||
import { copyTextToClipboard } from "../utils/copyToClipboard";
|
||||
import type {
|
||||
@@ -228,7 +228,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
const confirmContext = useConfirm();
|
||||
const viewportMode = useViewportMode();
|
||||
// FNXC:RightDockEmbedding 2026-06-22-00:00: embedded mode gates modal-only behaviors below (shared hook).
|
||||
const { isEmbedded, scrollLockEnabled, resizePersistEnabled, escapeEnabled } = useEmbeddedPresentation(presentation);
|
||||
const { isEmbedded, scrollLockEnabled, escapeEnabled } = useEmbeddedPresentation(presentation);
|
||||
useMobileScrollLock(isOpen && scrollLockEnabled);
|
||||
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({
|
||||
enabled: viewportMode === "mobile",
|
||||
@@ -257,9 +257,9 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sectionError, setSectionError] = useState<string | null>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const dismissOnOutsidePointerDown = useModalDismissPreference();
|
||||
// FNXC:RightDockEmbedding 2026-06-22-00:00: skip modal resize persist/restore when embedded inline.
|
||||
useModalResizePersist(modalRef, isOpen && resizePersistEnabled, "fusion:git-modal-size");
|
||||
const overlayDismissProps = useOverlayDismiss(handleClose);
|
||||
// FNXC:ModalTouchGeometry 2026-07-26-13:35: The modal-only branch below uses FloatingWindow for complete touch geometry; the embedded branch keeps its existing inline presentation.
|
||||
const copyToClipboard = useCopyToClipboard(addToast);
|
||||
|
||||
// ── Status state
|
||||
@@ -1380,8 +1380,24 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
);
|
||||
}
|
||||
|
||||
/* FNXC:ModalTouchGeometry 2026-07-26-14:25: Preserve Git Manager's pre-migration responsive desktop shell as the FloatingWindow seed so shared persistence does not shrink the surface. */
|
||||
return (
|
||||
<div className="modal-overlay open git-manager-modal-overlay" {...overlayDismissProps} role="dialog" aria-modal="true">
|
||||
<FloatingWindow
|
||||
windowKey="git-manager"
|
||||
title={t("git.modalTitle", "Git Manager")}
|
||||
ariaLabel={`${t("git.modalTitle", "Git Manager")} dialog`}
|
||||
onClose={handleClose}
|
||||
hideHeader
|
||||
dragHandleSelector=".modal-header"
|
||||
className="floating-window--git-manager"
|
||||
defaultSize={{ width: Math.min(window.innerWidth * 0.95, 1400), height: window.innerHeight * 0.92 }}
|
||||
minSize={{ width: 360, height: 280 }}
|
||||
persistGeometryKey="floating-window:git-manager"
|
||||
suspendGeometryPersistenceOnMobile
|
||||
suspendGeometryPersistenceOnShortViewport
|
||||
/* FNXC:ModalTouchGeometry 2026-07-26-16:10: Git Manager keeps the global default-off backdrop preference; FloatingWindow's guarded pointer listener preserves drag-safe outside dismissal. */
|
||||
closeOnOutsidePointerDown={dismissOnOutsidePointerDown}
|
||||
>
|
||||
<div className="modal gm-modal" ref={modalRef} style={keyboardStyle}>
|
||||
<div className="modal-header">
|
||||
<h3>
|
||||
@@ -1399,7 +1415,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
|
||||
{gitBody}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FloatingWindow>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { apiAbandonBranchGroup, apiGetBranchGroup, apiPromoteBranchGroup, type B
|
||||
import { subscribeSse } from "../sse-bus";
|
||||
import { BRANCH_GROUP_REFRESH_TASK_EVENTS, shouldRefreshBranchGroupForTaskEvent } from "../utils/branchGroupSse";
|
||||
|
||||
import { FloatingWindow } from "./FloatingWindow";
|
||||
interface GroupTaskModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -102,8 +103,9 @@ export function GroupTaskModal({ isOpen, onClose, groupId, projectId, onOpenMemb
|
||||
if (!isOpen || !groupId) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={onClose}>
|
||||
<div className="modal modal-lg group-task-modal" role="dialog" aria-modal="true" aria-label={t("groupTask.ariaLabel", "Branch group details")} onClick={(event) => event.stopPropagation()}>
|
||||
/* FNXC:ModalTouchGeometry 2026-07-26-13:15: Shared FloatingWindow owns this modal's touch drag, resize, clamping, and persistence while phone and short viewports retain their sheet behavior. */
|
||||
<FloatingWindow windowKey="group-task" title={t("groupTask.title", "Branch Group")} ariaLabel={`${t("groupTask.ariaLabel", "Branch group details")} dialog`} onClose={onClose} hideHeader dragHandleSelector=".modal-header" className="floating-window--group-task" defaultSize={{ width: 720, height: 560 }} minSize={{ width: 360, height: 280 }} persistGeometryKey="floating-window:group-task" suspendGeometryPersistenceOnMobile suspendGeometryPersistenceOnShortViewport closeOnOutsidePointerDown>
|
||||
<div className="modal modal-lg group-task-modal" aria-label={t("groupTask.ariaLabel", "Branch group details")}>
|
||||
<div className="modal-header">
|
||||
<h2>{t("groupTask.title", "Branch Group {{id}}", { id: groupId })}</h2>
|
||||
<button type="button" className="modal-close" onClick={onClose} aria-label={t("actions.closeModal", "Close modal")}>
|
||||
@@ -186,6 +188,6 @@ export function GroupTaskModal({ isOpen, onClose, groupId, projectId, onOpenMemb
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FloatingWindow>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
type GitCliStatus,
|
||||
} from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||
import { FloatingWindow } from "./FloatingWindow";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard";
|
||||
@@ -780,7 +780,6 @@ export function ModelOnboardingModal({
|
||||
const [shellConnectionError, setShellConnectionError] = useState<string | null>(null);
|
||||
const apiKeySuccessTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
||||
const onboardingContentRef = useRef<HTMLDivElement | null>(null);
|
||||
const modalRef = useRef<HTMLDivElement | null>(null);
|
||||
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const agentErrorRef = useRef<HTMLDivElement | null>(null);
|
||||
const [loginOutcomes, setLoginOutcomes] = useState<Record<string, LoginOutcome>>({});
|
||||
@@ -796,7 +795,7 @@ export function ModelOnboardingModal({
|
||||
const resumedFromStep = persistedState?.currentStep;
|
||||
const isResumedFlow = !!persistedState && persistedState.currentStep !== "complete";
|
||||
|
||||
useModalResizePersist(modalRef, isOpen, "fusion:model-onboarding-modal-size");
|
||||
// FNXC:ModalTouchGeometry 2026-07-26-13:30: Shared FloatingWindow replaces the legacy resize-only persistence path with clamped movable geometry.
|
||||
|
||||
// Scroll the content area to the top whenever the step changes so the user
|
||||
// always lands at the start of the next page instead of mid-scroll from the
|
||||
@@ -2436,13 +2435,22 @@ export function ModelOnboardingModal({
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay open"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="onboarding-title"
|
||||
<FloatingWindow
|
||||
windowKey="model-onboarding"
|
||||
title={t("setup.titleAiSetup", "Set Up AI")}
|
||||
onClose={handleDismiss}
|
||||
hideHeader
|
||||
dragHandleSelector=".model-onboarding-header"
|
||||
className="floating-window--model-onboarding"
|
||||
defaultSize={{ width: 720, height: 640 }}
|
||||
minSize={{ width: 360, height: 280 }}
|
||||
persistGeometryKey="floating-window:model-onboarding"
|
||||
suspendGeometryPersistenceOnMobile
|
||||
suspendGeometryPersistenceOnShortViewport
|
||||
ariaLabel={`${t("setup.titleAiSetup", "Set Up AI")} dialog`}
|
||||
ariaLabelledBy="onboarding-title"
|
||||
>
|
||||
<div className="modal model-onboarding-modal" ref={modalRef}>
|
||||
<div className="modal model-onboarding-modal">
|
||||
{/* Header */}
|
||||
<div className="model-onboarding-header">
|
||||
<h2 id="onboarding-title" className="model-onboarding-title">
|
||||
@@ -3589,6 +3597,6 @@ export function ModelOnboardingModal({
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</div>
|
||||
</FloatingWindow>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ const DOCKER_MOUNT_LABELS = {
|
||||
bind: "bind",
|
||||
} as const;
|
||||
|
||||
import { FloatingWindow } from "./FloatingWindow";
|
||||
interface NodeDetailModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -437,12 +438,10 @@ export function NodeDetailModal({
|
||||
const dockerStatusTone = getDockerStatusTone(effectiveDockerStatus);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={onClose}>
|
||||
/* FNXC:ModalTouchGeometry 2026-07-26-13:15: Shared FloatingWindow owns this modal's touch drag, resize, clamping, and persistence while phone and short viewports retain their sheet behavior. */
|
||||
<FloatingWindow windowKey="node-detail" title={t("nodes.modalTitle", "Node Details")} ariaLabel={`${t("nodes.modalAriaLabel", "Node details for {{name}}", { name: node.name })} dialog`} onClose={onClose} hideHeader dragHandleSelector=".modal-header" className="floating-window--node-detail" defaultSize={{ width: 720, height: 560 }} minSize={{ width: 360, height: 280 }} persistGeometryKey="floating-window:node-detail" suspendGeometryPersistenceOnMobile suspendGeometryPersistenceOnShortViewport closeOnOutsidePointerDown>
|
||||
<div
|
||||
className="modal modal-lg node-detail-modal"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("nodes.modalAriaLabel", "Node details for {{name}}", { name: node.name })}
|
||||
>
|
||||
<div className="modal-header">
|
||||
@@ -891,6 +890,6 @@ export function NodeDetailModal({
|
||||
addToast={addToast}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</FloatingWindow>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
min-height: 480px;
|
||||
max-height: calc(100dvh - var(--overlay-padding-top, 10vh) - 16px);
|
||||
overflow: hidden;
|
||||
resize: both;
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import "./PlanningModeModal.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useState, useCallback, useEffect, useRef, useMemo, type CSSProperties, type MouseEvent, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import { useState, useCallback, useEffect, useRef, useMemo, type CSSProperties, type ReactNode, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { Task, PlanningQuestion, PlanningSummary, TaskPriority, ThinkingLevel } from "@fusion/core";
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
type PlanningContextualComment,
|
||||
} from "../api";
|
||||
import { subscribeSse } from "../sse-bus";
|
||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||
import { FloatingWindow } from "./FloatingWindow";
|
||||
import { useEmbeddedPresentation, type ModalPresentation } from "../hooks/useEmbeddedPresentation";
|
||||
import {
|
||||
savePlanningDescription,
|
||||
@@ -473,7 +473,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
// FNXC:EmbeddedPresentation 2026-06-22-12:00: shared hook supplies isEmbedded (DOM branching) plus the modal-only gates.
|
||||
// Note: the Escape handler intentionally does NOT gate on embedded here — embedded planning preserves its historical
|
||||
// Escape-to-close behavior (the back-stack/onClose path), so escapeEnabled is deliberately not wired below.
|
||||
const { isEmbedded, scrollLockEnabled, resizePersistEnabled } = useEmbeddedPresentation(presentation);
|
||||
const { isEmbedded, scrollLockEnabled } = useEmbeddedPresentation(presentation);
|
||||
const [initialPlan, setInitialPlan] = useState("");
|
||||
/*
|
||||
FNXC:Planning 2026-07-15-00:00:
|
||||
@@ -699,11 +699,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
const [mobileShowDetail, setMobileShowDetail] = useState<boolean>(Boolean(resumeSessionId));
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
// Track whether the mousedown that initiated a click came from inside the
|
||||
// modal. Resizing via the bottom-right grip can release the mouse outside
|
||||
// the modal element; without this guard, that release fires a click whose
|
||||
// target is the overlay and would dismiss the modal mid-resize.
|
||||
const overlayMouseDownOnSelfRef = useRef(false);
|
||||
const thinkingOutputRef = useRef<HTMLDivElement>(null);
|
||||
// Mirrors `streamingOutput` state for reading inside callbacks without
|
||||
// stale closure issues (e.g. capturing reasoning before onQuestion clears it).
|
||||
@@ -717,7 +712,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
thinkingLevel?: ThinkingLevel | "";
|
||||
} | null>(null);
|
||||
|
||||
useModalResizePersist(modalRef, isOpen && resizePersistEnabled, "fusion:planning-modal-size");
|
||||
// FNXC:ModalTouchGeometry 2026-07-26-14:10: FloatingWindow replaces the resize-only grip for the modal branch; embedded Planning keeps its existing inline layout.
|
||||
const viewportMode = useViewportMode();
|
||||
const isMobile = viewportMode === "mobile";
|
||||
/*
|
||||
@@ -3544,23 +3539,33 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
</section>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={isEmbedded ? "planning-view open" : "modal-overlay open"}
|
||||
data-testid={isEmbedded ? "planning-view" : undefined}
|
||||
onMouseDown={isEmbedded ? undefined : (e: MouseEvent<HTMLDivElement>) => {
|
||||
overlayMouseDownOnSelfRef.current = e.target === e.currentTarget;
|
||||
}}
|
||||
onClick={isEmbedded ? undefined : (e: MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === e.currentTarget && overlayMouseDownOnSelfRef.current) {
|
||||
handleClose();
|
||||
}
|
||||
overlayMouseDownOnSelfRef.current = false;
|
||||
}}
|
||||
role={isEmbedded ? "region" : "dialog"}
|
||||
aria-label={isEmbedded ? t("planning.title", "Planning Mode") : undefined}
|
||||
aria-modal={isEmbedded ? undefined : "true"}
|
||||
/* FNXC:ModalTouchGeometry 2026-07-26-14:25: Preserve Planning Mode's pre-migration responsive desktop shell as the FloatingWindow seed so shared persistence does not shrink the surface. */
|
||||
const ModalShell = ({ children }: { children: ReactNode }) => isEmbedded ? (
|
||||
<div className="planning-view open" data-testid="planning-view" role="region" aria-label={t("planning.title", "Planning Mode")}>
|
||||
{children}
|
||||
</div>
|
||||
) : (
|
||||
<FloatingWindow
|
||||
windowKey="planning-mode"
|
||||
title={t("planning.title", "Planning Mode")}
|
||||
ariaLabel={`${t("planning.title", "Planning Mode")} dialog`}
|
||||
onClose={handleClose}
|
||||
hideHeader
|
||||
dragHandleSelector=".planning-modal > .modal-header"
|
||||
className="floating-window--planning-mode"
|
||||
defaultSize={{ width: Math.min(window.innerWidth * 0.95, 1200), height: window.innerHeight * 0.85 }}
|
||||
minSize={{ width: 360, height: 480 }}
|
||||
persistGeometryKey="floating-window:planning-mode"
|
||||
suspendGeometryPersistenceOnMobile
|
||||
suspendGeometryPersistenceOnShortViewport
|
||||
closeOnOutsidePointerDown
|
||||
>
|
||||
{children}
|
||||
</FloatingWindow>
|
||||
);
|
||||
|
||||
return (
|
||||
<ModalShell>
|
||||
<div className={isEmbedded ? "modal modal-lg planning-modal planning-modal--embedded" : "modal modal-lg planning-modal"} ref={modalRef}>
|
||||
{/*
|
||||
FNXC:PlanningMode 2026-06-22-00:00:
|
||||
@@ -4245,7 +4250,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -560,14 +560,21 @@ export function ScheduledTasksModal({ onClose, addToast, projectId, presentation
|
||||
<FloatingWindow
|
||||
windowKey="automation"
|
||||
title={t("schedule.title", "Automations")}
|
||||
ariaLabel={`${t("schedule.title", "Automations")} dialog`}
|
||||
onClose={onClose}
|
||||
hideHeader
|
||||
dragHandleSelector=".automation-modal__drag-handle"
|
||||
className="floating-window--automation"
|
||||
defaultSize={{ width: 720, height: 640 }}
|
||||
minSize={{ width: 420, height: 360 }}
|
||||
/* FNXC:ModalGeometryPersistence 2026-07-15-19:30: Automations is a full-screen sheet at ≤768px; its movable desktop geometry must survive mobile opens. */
|
||||
/*
|
||||
FNXC:ModalTouchGeometry 2026-07-26-13:00:
|
||||
Automations is the canonical headerless migration: its existing title row owns drag while
|
||||
FloatingWindow owns touch resize, clamping, stacking, and persistence. Suspend both phone
|
||||
and short-sheet viewports so saved desktop geometry never leaks into either full-screen form.
|
||||
*/
|
||||
suspendGeometryPersistenceOnMobile
|
||||
suspendGeometryPersistenceOnShortViewport
|
||||
persistGeometryKey="floating-window:automation"
|
||||
>
|
||||
{/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ import { getErrorMessage } from "@fusion/core";
|
||||
import { fetchScripts, addScript, removeScript, type ScriptEntry } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||
import { useModalDismissPreference } from "../hooks/useOverlayDismiss";
|
||||
import { FloatingWindow } from "./FloatingWindow";
|
||||
import {
|
||||
X,
|
||||
Plus,
|
||||
@@ -47,6 +48,7 @@ function truncateCommand(command: string, maxLength: number = 60): string {
|
||||
|
||||
export function ScriptsModal({ isOpen, onClose, addToast, projectId, onRunScript }: ScriptsModalProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const dismissOnOutsidePointerDown = useModalDismissPreference();
|
||||
useMobileScrollLock(isOpen);
|
||||
const [scripts, setScripts] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -56,7 +58,6 @@ export function ScriptsModal({ isOpen, onClose, addToast, projectId, onRunScript
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleteConfirmName, setDeleteConfirmName] = useState<string | null>(null);
|
||||
const [nameError, setNameError] = useState<string | null>(null);
|
||||
const overlayDismissProps = useOverlayDismiss(onClose);
|
||||
|
||||
const loadScripts = useCallback(async () => {
|
||||
try {
|
||||
@@ -176,13 +177,24 @@ export function ScriptsModal({ isOpen, onClose, addToast, projectId, onRunScript
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" {...overlayDismissProps} data-testid="scripts-modal">
|
||||
<div
|
||||
className="modal scripts-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("scripts.title", "Scripts")}
|
||||
>
|
||||
/* FNXC:ModalTouchGeometry 2026-07-26-13:20: Scripts retains its existing overlay-dismiss policy while FloatingWindow supplies the only drag/resize implementation and suspends desktop geometry in sheet viewports. */
|
||||
<FloatingWindow
|
||||
windowKey="scripts"
|
||||
title={t("scripts.title", "Scripts")}
|
||||
ariaLabel={`${t("scripts.title", "Scripts")} dialog`}
|
||||
onClose={onClose}
|
||||
hideHeader
|
||||
dragHandleSelector=".modal-header"
|
||||
className="floating-window--scripts"
|
||||
defaultSize={{ width: 720, height: 560 }}
|
||||
minSize={{ width: 360, height: 280 }}
|
||||
persistGeometryKey="floating-window:scripts"
|
||||
suspendGeometryPersistenceOnMobile
|
||||
suspendGeometryPersistenceOnShortViewport
|
||||
/* FNXC:ModalTouchGeometry 2026-07-26-16:10: Preserve Scripts' globally default-off backdrop preference while the shared window keeps drag gestures from being mistaken for outside dismissals. */
|
||||
closeOnOutsidePointerDown={dismissOnOutsidePointerDown}
|
||||
>
|
||||
<div className="modal scripts-modal" aria-label={t("scripts.title", "Scripts")}>
|
||||
{/* Header */}
|
||||
<div className="modal-header">
|
||||
<h2>
|
||||
@@ -490,6 +502,6 @@ export function ScriptsModal({ isOpen, onClose, addToast, projectId, onRunScript
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FloatingWindow>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -120,7 +120,6 @@
|
||||
min-height: 480px;
|
||||
max-height: calc(100dvh - var(--overlay-padding-top, 10vh) - 16px);
|
||||
overflow: hidden;
|
||||
resize: both;
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef, type CSSProperties, type Dispatch, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent, type PointerEvent as ReactPointerEvent, type SetStateAction } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo, useRef, type CSSProperties, type Dispatch, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, type SetStateAction } from "react";
|
||||
import { Globe, Folder, GitBranch, Power, RefreshCw, Star, Settings as SettingsIcon, Search, X as SearchToggleCloseIcon } from "lucide-react";
|
||||
import {
|
||||
getErrorMessage,
|
||||
@@ -66,14 +66,13 @@ import { DatabaseBackupsSection } from "./settings/sections/DatabaseBackupsSecti
|
||||
import { LoadingSpinner } from "./LoadingSpinner";
|
||||
import { PluginsSection } from "./settings/sections/PluginsSection";
|
||||
import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
|
||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSessionBannersHidden, setSessionBannersHidden } from "../hooks/useSessionBannerPref";
|
||||
import "./SettingsModal.css";
|
||||
import { FileBrowser } from "./FileBrowser";
|
||||
import { useWorkspaceFileBrowser } from "../hooks/useWorkspaceFileBrowser";
|
||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||
import { FloatingWindow } from "./FloatingWindow";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { generateUniquePresetId } from "../utils/modelPresets";
|
||||
import { copyTextToClipboard } from "../utils/copyToClipboard";
|
||||
@@ -1122,7 +1121,7 @@ export function SettingsModal({
|
||||
onOpenWorkflowSettings,
|
||||
presentation = "modal",
|
||||
}: SettingsModalProps) {
|
||||
const { isEmbedded, scrollLockEnabled, resizePersistEnabled, escapeEnabled, overlayDismissEnabled } = useEmbeddedPresentation(presentation);
|
||||
const { isEmbedded, scrollLockEnabled, escapeEnabled, overlayDismissEnabled } = useEmbeddedPresentation(presentation);
|
||||
const { t } = useTranslation("app");
|
||||
const { confirm } = useConfirm();
|
||||
const viewportMode = useViewportMode();
|
||||
@@ -1138,7 +1137,6 @@ export function SettingsModal({
|
||||
...(viewportHeight !== null ? { "--vv-height": `${viewportHeight}px` } : {}),
|
||||
} as CSSProperties)
|
||||
: {};
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const settingsContentRef = useRef<HTMLDivElement>(null);
|
||||
const workflowLaneSaverRef = useRef<SectionSaveHandler | null>(null);
|
||||
/*
|
||||
@@ -1162,8 +1160,7 @@ export function SettingsModal({
|
||||
workflowLaneSaverRef.current = saver;
|
||||
}
|
||||
}, []);
|
||||
// Modal-only: persist user-resized dialog dimensions. Embedded view fills its host and is not resizable.
|
||||
useModalResizePersist(modalRef, resizePersistEnabled, "fusion:settings-modal-size");
|
||||
// FNXC:ModalTouchGeometry 2026-07-26-14:10: FloatingWindow owns movable, clamped geometry for the modal branch; the embedded Settings view remains an inline, chrome-free destination.
|
||||
const sessionBannersHidden = useSessionBannersHidden();
|
||||
const [form, setForm] = useState<SettingsFormState>({
|
||||
maxConcurrent: 2,
|
||||
@@ -3909,14 +3906,6 @@ export function SettingsModal({
|
||||
return () => document.removeEventListener("keydown", handleKey);
|
||||
}, [escapeEnabled, requestClose, resetDialogOpen]);
|
||||
|
||||
const modalOverlayDismissProps = useOverlayDismiss(() => { void requestClose(); });
|
||||
/*
|
||||
FNXC:SettingsAutoSave 2026-08-02-21:45:
|
||||
Backdrop dismissal remains preference-gated, but every enabled modal path
|
||||
shares requestClose so its latest dirty snapshot is flushed before unmount.
|
||||
*/
|
||||
const overlayDismissProps = !isEmbedded && overlayDismissEnabled ? modalOverlayDismissProps : {};
|
||||
|
||||
|
||||
/*
|
||||
FNXC:SettingsReset 2026-07-04-00:25:
|
||||
@@ -4630,18 +4619,39 @@ export function SettingsModal({
|
||||
FNXC:Settings 2026-06-22-00:00:
|
||||
Embedded settings is a main-content destination, not a dialog. It drops the fixed `.modal-overlay` backdrop and the inner card chrome (modal-overlay/modal/settings-modal classes), and instead uses `settings-embedded right-dock-embedded-view` (host) + `settings-modal--embedded` (panel) to fill the pane flush like other embedded views (Planning, Command Center). The modal path stays byte-identical.
|
||||
*/
|
||||
return (
|
||||
const ModalShell = ({ children }: { children: ReactNode }) => isEmbedded ? (
|
||||
<div
|
||||
className={isEmbedded ? "settings-embedded right-dock-embedded-view" : "modal-overlay open settings-modal-overlay"}
|
||||
{...overlayDismissProps}
|
||||
data-testid={isEmbedded ? "settings-view" : undefined}
|
||||
role={isEmbedded ? "region" : "dialog"}
|
||||
aria-label={isEmbedded ? t("settings.title", "Settings") : undefined}
|
||||
aria-modal={isEmbedded ? undefined : "true"}
|
||||
className="settings-embedded right-dock-embedded-view"
|
||||
data-testid="settings-view"
|
||||
role="region"
|
||||
aria-label={t("settings.title", "Settings")}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
) : (
|
||||
<FloatingWindow
|
||||
windowKey="settings"
|
||||
title={t("settings.title", "Settings")}
|
||||
ariaLabel={`${t("settings.title", "Settings")} dialog`}
|
||||
onClose={() => void requestClose()}
|
||||
hideHeader
|
||||
dragHandleSelector=".settings-modal > .modal-header"
|
||||
className="floating-window--settings"
|
||||
defaultSize={{ width: 1100, height: 720 }}
|
||||
minSize={{ width: 520, height: 480 }}
|
||||
persistGeometryKey="floating-window:settings"
|
||||
suspendGeometryPersistenceOnMobile
|
||||
suspendGeometryPersistenceOnShortViewport
|
||||
closeOnOutsidePointerDown={overlayDismissEnabled}
|
||||
>
|
||||
{children}
|
||||
</FloatingWindow>
|
||||
);
|
||||
|
||||
return (
|
||||
<ModalShell>
|
||||
<div
|
||||
className={isEmbedded ? "modal modal-lg settings-modal settings-modal--embedded" : "modal modal-lg settings-modal"}
|
||||
ref={modalRef}
|
||||
style={isEmbedded ? undefined : keyboardStyle}
|
||||
>
|
||||
<div className={isEmbedded ? "modal-header modal-header--embedded" : "modal-header"}>
|
||||
@@ -5381,7 +5391,7 @@ export function SettingsModal({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { X, Search, Puzzle, ToggleRight } from "lucide-react";
|
||||
import type { WorkflowDefinition, WorkflowStepTemplate } from "@fusion/core";
|
||||
import type { WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes";
|
||||
import { nodeHelpFor } from "./nodes/node-help";
|
||||
import { FloatingWindow } from "./FloatingWindow";
|
||||
import "./WorkflowAddStepModal.css";
|
||||
|
||||
/*
|
||||
@@ -127,28 +128,35 @@ export function WorkflowAddStepModal({
|
||||
filteredFragments.length > 0 || filteredStepTemplates.length > 0 || filteredPluginTemplates.length > 0;
|
||||
const hasAnyResult = categories.length > 0 || hasTemplates;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose, open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="wf-add-step-overlay"
|
||||
data-testid="wf-add-step-modal"
|
||||
role="presentation"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
/* FNXC:ModalTouchGeometry 2026-07-26-13:20: The workflow palette uses the shared touch-actuable geometry primitive instead of its bespoke overlay; the existing header stays the drag handle. */
|
||||
<FloatingWindow
|
||||
windowKey="workflow-add-step"
|
||||
title={t("workflowNodes.addStepTitle", "Add a step")}
|
||||
ariaLabel={`${t("workflowNodes.addStepTitle", "Add a step")} dialog`}
|
||||
onClose={onClose}
|
||||
hideHeader
|
||||
dragHandleSelector=".wf-add-step-header"
|
||||
className="floating-window--workflow-add-step"
|
||||
defaultSize={{ width: 640, height: 560 }}
|
||||
minSize={{ width: 360, height: 280 }}
|
||||
persistGeometryKey="floating-window:workflow-add-step"
|
||||
suspendGeometryPersistenceOnMobile
|
||||
suspendGeometryPersistenceOnShortViewport
|
||||
closeOnOutsidePointerDown
|
||||
>
|
||||
<div
|
||||
className="wf-add-step-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("workflowNodes.addStepTitle", "Add a step")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="wf-add-step-dialog" aria-label={t("workflowNodes.addStepTitle", "Add a step")}>
|
||||
<header className="wf-add-step-header">
|
||||
<h3>{t("workflowNodes.addStepTitle", "Add a step")}</h3>
|
||||
<button
|
||||
@@ -297,6 +305,6 @@ export function WorkflowAddStepModal({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FloatingWindow>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { ActivityLogModal } from "../ActivityLogModal";
|
||||
import { assertModalGeometryRecoveryAndSheetContracts, assertRenderedModalTouchGeometry } from "./floatingWindowMigration.test-helpers";
|
||||
import * as apiModule from "../../api";
|
||||
import type { ActivityLogEntry, Task } from "@fusion/core";
|
||||
|
||||
@@ -130,7 +131,7 @@ describe("ActivityLogModal", () => {
|
||||
{ id: "older", timestamp: "2026-06-10T20:00:00.000Z", type: "task:created", details: "older" },
|
||||
] as ActivityLogEntry[]);
|
||||
|
||||
const { container } = render(
|
||||
const { baseElement } = render(
|
||||
<ActivityLogModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
@@ -140,7 +141,7 @@ describe("ActivityLogModal", () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const times = Array.from(container.querySelectorAll(".activity-log-entry-time")).map((node) => node.textContent);
|
||||
const times = Array.from(baseElement.querySelectorAll(".activity-log-entry-time")).map((node) => node.textContent);
|
||||
expect(times).toEqual(expect.arrayContaining([
|
||||
"Just now",
|
||||
"5m ago",
|
||||
@@ -168,7 +169,7 @@ describe("ActivityLogModal", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const { container } = render(
|
||||
const { baseElement } = render(
|
||||
<ActivityLogModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
@@ -178,12 +179,12 @@ describe("ActivityLogModal", () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const labels = Array.from(container.querySelectorAll(".activity-log-entry-type")).map((node) => node.textContent);
|
||||
const labels = Array.from(baseElement.querySelectorAll(".activity-log-entry-type")).map((node) => node.textContent);
|
||||
expect(labels).toContain("Task Auto-Archived (Ghost Bug)");
|
||||
expect(labels).toContain("Task Auto-Archived (Duplicate)");
|
||||
});
|
||||
|
||||
const icons = container.querySelectorAll(".activity-log-entry-icon .activity-icon");
|
||||
const icons = baseElement.querySelectorAll(".activity-log-entry-icon .activity-icon");
|
||||
expect(icons).toHaveLength(2);
|
||||
});
|
||||
|
||||
@@ -550,7 +551,7 @@ describe("ActivityLogModal", () => {
|
||||
// ── Responsive Layout Regression Tests ───────────────────────────
|
||||
|
||||
it("renders all mobile-responsive CSS classes on the modal structure", async () => {
|
||||
const { container } = render(
|
||||
const { baseElement } = render(
|
||||
<ActivityLogModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
@@ -565,7 +566,7 @@ describe("ActivityLogModal", () => {
|
||||
});
|
||||
|
||||
// Verify key structural classes that the mobile CSS targets
|
||||
const modal = container.querySelector(".activity-log-modal");
|
||||
const modal = baseElement.querySelector(".activity-log-modal");
|
||||
expect(modal).toBeTruthy();
|
||||
// Modal uses shared modal-lg for consistent wide sizing
|
||||
expect(modal!.classList.contains("modal-lg")).toBe(true);
|
||||
@@ -580,7 +581,7 @@ describe("ActivityLogModal", () => {
|
||||
});
|
||||
|
||||
it("renders close button with shared modal-close class and accessibility attributes", async () => {
|
||||
const { container } = render(
|
||||
const { baseElement } = render(
|
||||
<ActivityLogModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
@@ -596,16 +597,16 @@ describe("ActivityLogModal", () => {
|
||||
// Has accessibility label
|
||||
expect(closeButton.getAttribute("aria-label")).toBe("Close");
|
||||
// Close button is a direct child of the header, NOT inside the actions row
|
||||
const header = container.querySelector(".modal-header");
|
||||
const header = baseElement.querySelector(".modal-header");
|
||||
expect(header).toBeTruthy();
|
||||
expect(header!.contains(closeButton)).toBe(true);
|
||||
const actions = container.querySelector(".activity-log-actions");
|
||||
const actions = baseElement.querySelector(".activity-log-actions");
|
||||
expect(actions).toBeTruthy();
|
||||
expect(actions!.contains(closeButton)).toBe(false);
|
||||
});
|
||||
|
||||
it("renders entry header and details within each entry for mobile reflow", async () => {
|
||||
const { container } = render(
|
||||
const { baseElement } = render(
|
||||
<ActivityLogModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
@@ -619,7 +620,7 @@ describe("ActivityLogModal", () => {
|
||||
});
|
||||
|
||||
// Each entry should have the inner structure that mobile CSS reflows
|
||||
const entries = container.querySelectorAll(".activity-log-entry");
|
||||
const entries = baseElement.querySelectorAll(".activity-log-entry");
|
||||
for (const entry of entries) {
|
||||
expect(entry.querySelector(".activity-log-entry-icon")).toBeTruthy();
|
||||
expect(entry.querySelector(".activity-log-entry-content")).toBeTruthy();
|
||||
@@ -680,3 +681,11 @@ describe("ActivityLogModal", () => {
|
||||
expect(actions!.querySelector(".activity-log-confirm-clear")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ActivityLogModal floating geometry", () => {
|
||||
it("uses its production header for touch drag and resize", () => {
|
||||
render(<ActivityLogModal isOpen onClose={vi.fn()} tasks={[]} onOpenTaskDetail={vi.fn()} />);
|
||||
assertRenderedModalTouchGeometry("activity-log", screen.getByText("Activity Log").closest(".modal-header") as HTMLElement);
|
||||
assertModalGeometryRecoveryAndSheetContracts("activity-log", () => render(<ActivityLogModal isOpen onClose={vi.fn()} tasks={[]} onOpenTaskDetail={vi.fn()} />));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { AddNodeModal } from "../AddNodeModal";
|
||||
import { assertModalGeometryRecoveryAndSheetContracts, assertRenderedModalTouchGeometry } from "./floatingWindowMigration.test-helpers";
|
||||
|
||||
describe("AddNodeModal", () => {
|
||||
const defaultProps = {
|
||||
@@ -217,8 +218,7 @@ describe("AddNodeModal", () => {
|
||||
render(<AddNodeModal {...defaultProps} />);
|
||||
|
||||
// Click on the overlay (not the modal itself)
|
||||
const overlay = screen.getByRole("dialog").parentElement!;
|
||||
fireEvent.click(overlay);
|
||||
fireEvent.pointerDown(document.body);
|
||||
|
||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||
});
|
||||
@@ -458,3 +458,11 @@ describe("AddNodeModal", () => {
|
||||
expect(screen.queryByDisplayValue("/workspace/project-one")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddNodeModal floating geometry", () => {
|
||||
it("uses its production header for touch drag and resize", () => {
|
||||
render(<AddNodeModal isOpen onClose={vi.fn()} onSubmit={vi.fn()} onDiscoverRemoteProjects={vi.fn()} addToast={vi.fn()} projects={[]} />);
|
||||
assertRenderedModalTouchGeometry("add-node", screen.getByTestId("floating-window-add-node").querySelector(".modal-header") as HTMLElement);
|
||||
assertModalGeometryRecoveryAndSheetContracts("add-node", () => render(<AddNodeModal isOpen onClose={vi.fn()} onSubmit={vi.fn()} onDiscoverRemoteProjects={vi.fn()} addToast={vi.fn()} projects={[]} />));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { loadAllAppCss } from "../../test/cssFixture";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { ChangesDiffModal, type NormalizedFile } from "../ChangesDiffModal";
|
||||
import { assertModalGeometryRecoveryAndSheetContracts, assertRenderedModalTouchGeometry } from "./floatingWindowMigration.test-helpers";
|
||||
import { ModalDismissPreferenceProvider } from "../../hooks/useOverlayDismiss";
|
||||
import type { MergeDetails } from "@fusion/core";
|
||||
|
||||
@@ -98,23 +99,23 @@ describe("ChangesDiffModal", () => {
|
||||
});
|
||||
|
||||
it("shows status badges with correct labels", () => {
|
||||
const { container } = render(
|
||||
const { baseElement } = render(
|
||||
<ChangesDiffModal {...defaultProps} />,
|
||||
);
|
||||
|
||||
const modifiedBadge = container.querySelector(
|
||||
const modifiedBadge = baseElement.querySelector(
|
||||
".changes-file-status--modified",
|
||||
);
|
||||
expect(modifiedBadge).toBeTruthy();
|
||||
expect(modifiedBadge?.textContent).toBe("M");
|
||||
|
||||
const addedBadge = container.querySelector(
|
||||
const addedBadge = baseElement.querySelector(
|
||||
".changes-file-status--added",
|
||||
);
|
||||
expect(addedBadge).toBeTruthy();
|
||||
expect(addedBadge?.textContent).toBe("A");
|
||||
|
||||
const deletedBadge = container.querySelector(
|
||||
const deletedBadge = baseElement.querySelector(
|
||||
".changes-file-status--deleted",
|
||||
);
|
||||
expect(deletedBadge).toBeTruthy();
|
||||
@@ -140,11 +141,11 @@ describe("ChangesDiffModal", () => {
|
||||
});
|
||||
|
||||
it("shows diff content for selected file", () => {
|
||||
const { container } = render(
|
||||
const { baseElement } = render(
|
||||
<ChangesDiffModal {...defaultProps} />,
|
||||
);
|
||||
// The first file's patch should be visible
|
||||
const patchEl = container.querySelector(".changes-diff-patch");
|
||||
const patchEl = baseElement.querySelector(".changes-diff-patch");
|
||||
expect(patchEl).toBeTruthy();
|
||||
expect(patchEl?.textContent).toContain("@@ -1,3 +1,6 @@");
|
||||
});
|
||||
@@ -197,11 +198,11 @@ describe("ChangesDiffModal", () => {
|
||||
});
|
||||
|
||||
it("sets title tooltips on diff file path elements", () => {
|
||||
const { container } = render(<ChangesDiffModal {...defaultProps} />);
|
||||
const { baseElement } = render(<ChangesDiffModal {...defaultProps} />);
|
||||
|
||||
const firstPath = container.querySelector('.changes-diff-file-path[title="src/app.ts"]');
|
||||
const secondPath = container.querySelector('.changes-diff-file-path[title="src/new-file.ts"]');
|
||||
const thirdPath = container.querySelector('.changes-diff-file-path[title="src/deleted.ts"]');
|
||||
const firstPath = baseElement.querySelector('.changes-diff-file-path[title="src/app.ts"]');
|
||||
const secondPath = baseElement.querySelector('.changes-diff-file-path[title="src/new-file.ts"]');
|
||||
const thirdPath = baseElement.querySelector('.changes-diff-file-path[title="src/deleted.ts"]');
|
||||
|
||||
expect(firstPath).toBeTruthy();
|
||||
expect(secondPath).toBeTruthy();
|
||||
@@ -227,12 +228,12 @@ describe("ChangesDiffModal", () => {
|
||||
});
|
||||
|
||||
it("highlights the selected file in sidebar", () => {
|
||||
const { container } = render(
|
||||
const { baseElement } = render(
|
||||
<ChangesDiffModal {...defaultProps} />,
|
||||
);
|
||||
|
||||
// First file should be selected by default
|
||||
const selectedItems = container.querySelectorAll(
|
||||
const selectedItems = baseElement.querySelectorAll(
|
||||
".changes-diff-file-item.selected",
|
||||
);
|
||||
expect(selectedItems.length).toBe(1);
|
||||
@@ -241,7 +242,7 @@ describe("ChangesDiffModal", () => {
|
||||
// Click on second file
|
||||
fireEvent.click(screen.getByText("src/new-file.ts"));
|
||||
|
||||
const newSelectedItems = container.querySelectorAll(
|
||||
const newSelectedItems = baseElement.querySelectorAll(
|
||||
".changes-diff-file-item.selected",
|
||||
);
|
||||
expect(newSelectedItems.length).toBe(1);
|
||||
@@ -340,7 +341,7 @@ describe("ChangesDiffModal", () => {
|
||||
});
|
||||
|
||||
it("toggles word wrap OFF when clicked", () => {
|
||||
const { container } = render(
|
||||
const { baseElement } = render(
|
||||
<ChangesDiffModal {...defaultProps} />,
|
||||
);
|
||||
|
||||
@@ -348,7 +349,7 @@ describe("ChangesDiffModal", () => {
|
||||
fireEvent.click(toggle);
|
||||
|
||||
// Should now have nowrap class
|
||||
const patchEl = container.querySelector(".changes-diff-patch");
|
||||
const patchEl = baseElement.querySelector(".changes-diff-patch");
|
||||
expect(patchEl?.classList.contains("changes-diff-patch--nowrap")).toBe(
|
||||
true,
|
||||
);
|
||||
@@ -382,46 +383,35 @@ describe("ChangesDiffModal", () => {
|
||||
|
||||
it("calls onClose when clicking the modal overlay", () => {
|
||||
const onClose = vi.fn();
|
||||
/*
|
||||
FNXC:ChangesDiffModal 2026-07-07-09:20:
|
||||
FN-7261 (global modal dismissal setting) made backdrop dismissal default-off: useOverlayDismiss only closes when ModalDismissPreferenceProvider enables it. Wrap the render in the provider so the overlay-click dismiss path is exercised (matches AgentErrorDetailsModal.test.tsx).
|
||||
*/
|
||||
const { container } = render(
|
||||
const { baseElement } = render(
|
||||
<ModalDismissPreferenceProvider enabled>
|
||||
<ChangesDiffModal {...defaultProps} onClose={onClose} />
|
||||
</ModalDismissPreferenceProvider>,
|
||||
);
|
||||
|
||||
const overlay = container.querySelector(".modal-overlay");
|
||||
expect(overlay).toBeTruthy();
|
||||
// Overlay dismiss is wired via mousedown→mouseup so a resize-drag that
|
||||
// ends on the overlay doesn't close the modal. A real click on the
|
||||
// overlay fires both events on the overlay element.
|
||||
fireEvent.mouseDown(overlay!);
|
||||
fireEvent.mouseUp(overlay!);
|
||||
expect(baseElement.querySelector(".floating-window--changes-diff")).toBeTruthy();
|
||||
fireEvent.pointerDown(document.body);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does NOT call onClose when mousedown is on the modal but mouseup is on the overlay (resize drag)", () => {
|
||||
const onClose = vi.fn();
|
||||
const { container } = render(
|
||||
const { baseElement } = render(
|
||||
<ChangesDiffModal {...defaultProps} onClose={onClose} />,
|
||||
);
|
||||
|
||||
const overlay = container.querySelector(".modal-overlay")!;
|
||||
const modal = container.querySelector(".changes-diff-modal")!;
|
||||
fireEvent.mouseDown(modal);
|
||||
fireEvent.mouseUp(overlay);
|
||||
const modal = baseElement.querySelector(".changes-diff-modal")!;
|
||||
fireEvent.pointerDown(modal);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT call onClose when clicking inside the modal body", () => {
|
||||
const onClose = vi.fn();
|
||||
const { container } = render(
|
||||
const { baseElement } = render(
|
||||
<ChangesDiffModal {...defaultProps} onClose={onClose} />,
|
||||
);
|
||||
|
||||
const modal = container.querySelector(".modal.changes-diff-modal");
|
||||
const modal = baseElement.querySelector(".modal.changes-diff-modal");
|
||||
expect(modal).toBeTruthy();
|
||||
fireEvent.click(modal!);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
@@ -492,7 +482,7 @@ describe("ChangesDiffModal", () => {
|
||||
});
|
||||
|
||||
it("does not show commit metadata when column is not done", () => {
|
||||
const { container } = render(
|
||||
const { baseElement } = render(
|
||||
<ChangesDiffModal
|
||||
{...defaultProps}
|
||||
column="in-progress"
|
||||
@@ -500,15 +490,15 @@ describe("ChangesDiffModal", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".commit-diff-meta")).toBeNull();
|
||||
expect(baseElement.querySelector(".commit-diff-meta")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not show commit metadata when mergeDetails not provided", () => {
|
||||
const { container } = render(
|
||||
const { baseElement } = render(
|
||||
<ChangesDiffModal {...defaultProps} column="done" />,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".commit-diff-meta")).toBeNull();
|
||||
expect(baseElement.querySelector(".commit-diff-meta")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows partial commit metadata (SHA only)", () => {
|
||||
@@ -580,11 +570,11 @@ describe("ChangesDiffModal", () => {
|
||||
|
||||
describe("modal height constraint regression", () => {
|
||||
it("max-height uses calc() to stay within viewport padding", async () => {
|
||||
const { container } = render(
|
||||
const { baseElement } = render(
|
||||
<ChangesDiffModal {...defaultProps} />,
|
||||
);
|
||||
|
||||
const modal = container.querySelector(".changes-diff-modal");
|
||||
const modal = baseElement.querySelector(".changes-diff-modal");
|
||||
expect(modal).toBeTruthy();
|
||||
|
||||
// Load the stylesheet and verify the .changes-diff-modal rule contains
|
||||
@@ -639,3 +629,11 @@ describe("ChangesDiffModal", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChangesDiffModal floating geometry", () => {
|
||||
it("uses its production header for touch drag and resize", () => {
|
||||
render(<ChangesDiffModal {...defaultProps} />);
|
||||
assertRenderedModalTouchGeometry("changes-diff", screen.getByTestId("floating-window-changes-diff").querySelector(".modal-header") as HTMLElement);
|
||||
assertModalGeometryRecoveryAndSheetContracts("changes-diff", () => render(<ChangesDiffModal {...defaultProps} />));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { ConnectNodeModal } from "../ConnectNodeModal";
|
||||
import { assertModalGeometryRecoveryAndSheetContracts, assertRenderedModalTouchGeometry } from "./floatingWindowMigration.test-helpers";
|
||||
import type { NodeInfo } from "../../api";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
@@ -47,6 +48,12 @@ describe("ConnectNodeModal", () => {
|
||||
expect(screen.getByPlaceholderText("192.0.2.10 or my-server.local")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses its real header as a touch-actuable FloatingWindow drag handle", () => {
|
||||
render(<ConnectNodeModal {...defaultProps} />);
|
||||
assertRenderedModalTouchGeometry("connect-node", screen.getByText("Connect to Node").closest(".modal-header") as HTMLElement);
|
||||
assertModalGeometryRecoveryAndSheetContracts("connect-node", () => render(<ConnectNodeModal {...defaultProps} />));
|
||||
});
|
||||
|
||||
it("does not render when closed", () => {
|
||||
render(<ConnectNodeModal {...defaultProps} open={false} />);
|
||||
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { loadAllAppCss, loadStylesCss } from "../../test/cssFixture";
|
||||
import { FLOATING_WINDOW_GEOMETRY_CHANGE_EVENT, FloatingWindow } from "../FloatingWindow";
|
||||
import { readAppFile } from "../../test/cssFixture";
|
||||
import { dragWithTouch, expectFloatingWindowStructure, resizeWithTouch } from "./floatingWindowMigration.test-helpers";
|
||||
|
||||
const floatingWindowCss = readAppFile("components/FloatingWindow.css");
|
||||
const chatViewCss = readAppFile("components/ChatView.css");
|
||||
const allAppCss = loadAllAppCss();
|
||||
const stylesCss = loadStylesCss();
|
||||
|
||||
const FN_8606_WINDOW_IDENTITIES = [
|
||||
["ActivityLogModal.tsx", "activity-log"], ["ScriptsModal.tsx", "scripts"], ["ScheduledTasksModal.tsx", "automation"],
|
||||
["SettingsModal.tsx", "settings"], ["GitManagerModal.tsx", "git-manager"], ["PlanningModeModal.tsx", "planning-mode"],
|
||||
["ChangesDiffModal.tsx", "changes-diff"], ["ModelOnboardingModal.tsx", "model-onboarding"], ["AddNodeModal.tsx", "add-node"],
|
||||
["ConnectNodeModal.tsx", "connect-node"], ["NodeDetailModal.tsx", "node-detail"], ["WorkflowAddStepModal.tsx", "workflow-add-step"],
|
||||
["GroupTaskModal.tsx", "group-task"],
|
||||
] as const;
|
||||
|
||||
const QUICK_CHAT_PORTALED_MENU_CLASSES = [
|
||||
"model-combobox-dropdown--portal",
|
||||
"model-nested-menu--portal",
|
||||
@@ -137,10 +145,7 @@ describe("FloatingWindow", () => {
|
||||
</FloatingWindow>
|
||||
);
|
||||
expect(screen.getByTestId("floating-window-drag-handle-beta")).toBeTruthy();
|
||||
// 8 edge/corner resize handles.
|
||||
for (const dir of ["n", "s", "e", "w", "ne", "nw", "se", "sw"]) {
|
||||
expect(screen.getByTestId(`floating-window-resize-${dir}`)).toBeTruthy();
|
||||
}
|
||||
expectFloatingWindowStructure("beta");
|
||||
});
|
||||
|
||||
it("keeps every shared floating-window scrollbar inboard of the right resize hot zones", () => {
|
||||
@@ -285,7 +290,7 @@ describe("FloatingWindow", () => {
|
||||
expect(allAppCss).toContain("* {");
|
||||
expect(allAppCss).toContain("#root {");
|
||||
|
||||
const movableFloatingWindowSelector = ".floating-window:not(.floating-window--chat):not(.floating-window--github-import-detail):not(.floating-window--task-detail):not(.floating-window--workflow-editor):not(.floating-window--automation):not(.floating-window--mission-interview):not(.floating-window--file-browser):not(.floating-window--pr-create):not(.artifacts-gallery-window) .floating-window__header";
|
||||
const movableFloatingWindowSelector = ".floating-window:not(.floating-window--chat):not(.floating-window--github-import-detail):not(.floating-window--task-detail):not(.floating-window--workflow-editor):not(.floating-window--automation):not(.floating-window--mission-interview):not(.floating-window--file-browser):not(.floating-window--pr-create):not(.floating-window--activity-log):not(.floating-window--scripts):not(.floating-window--add-node):not(.floating-window--connect-node):not(.floating-window--node-detail):not(.floating-window--workflow-add-step):not(.floating-window--group-task):not(.floating-window--changes-diff):not(.floating-window--model-onboarding):not(.floating-window--git-manager):not(.floating-window--settings):not(.floating-window--planning-mode):not(.artifacts-gallery-window) .floating-window__header";
|
||||
expect(cssRuleFor(floatingWindowCss, movableFloatingWindowSelector)).toContain("touch-action: none;");
|
||||
|
||||
for (const selector of [
|
||||
@@ -296,6 +301,18 @@ describe("FloatingWindow", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("maps every FN-8606 modal to the required shared window identity and sheet suspension", () => {
|
||||
for (const [file, windowKey] of FN_8606_WINDOW_IDENTITIES) {
|
||||
const source = readAppFile(`components/${file}`);
|
||||
expect(source, file).toContain(`<FloatingWindow`);
|
||||
expect(source, file).toContain(`windowKey=\"${windowKey}\"`);
|
||||
expect(source, file).toContain(`className=\"floating-window--${windowKey}\"`);
|
||||
expect(source, file).toContain(`persistGeometryKey=\"floating-window:${windowKey}\"`);
|
||||
expect(source, file).toContain("suspendGeometryPersistenceOnMobile");
|
||||
expect(source, file).toContain("suspendGeometryPersistenceOnShortViewport");
|
||||
}
|
||||
});
|
||||
|
||||
it("gives only delegated Quick Chat headers a larger tablet touch target", () => {
|
||||
const tabletRule = mediaBlockFor(
|
||||
chatViewCss,
|
||||
@@ -835,6 +852,34 @@ describe("FloatingWindow", () => {
|
||||
expect(JSON.parse(localStorage.getItem(key) ?? "{}")).toEqual({ size: { width: 620, height: 450 }, position: { x: 100, y: 80 } });
|
||||
});
|
||||
|
||||
it("suppresses header drag and persistence in an opt-in short sheet", () => {
|
||||
const key = "floating-window:short-sheet-gesture";
|
||||
setSheetViewport(false);
|
||||
render(
|
||||
<FloatingWindow
|
||||
windowKey="short-sheet-gesture"
|
||||
title="Short sheet"
|
||||
onClose={() => {}}
|
||||
persistGeometryKey={key}
|
||||
suspendGeometryPersistenceOnMobile
|
||||
suspendGeometryPersistenceOnShortViewport
|
||||
defaultPosition={{ x: 80, y: 90 }}
|
||||
>
|
||||
<div>short sheet body</div>
|
||||
</FloatingWindow>,
|
||||
);
|
||||
|
||||
const panel = screen.getByTestId("floating-window-short-sheet-gesture");
|
||||
const header = screen.getByTestId("floating-window-drag-handle-short-sheet-gesture");
|
||||
fireEvent.pointerDown(header, { pointerId: 91, pointerType: "touch", clientX: 100, clientY: 100 });
|
||||
fireEvent.pointerMove(header, { pointerId: 91, pointerType: "touch", clientX: 160, clientY: 150 });
|
||||
fireEvent.pointerUp(header, { pointerId: 91, pointerType: "touch", clientX: 160, clientY: 150 });
|
||||
|
||||
expect(panel.style.left).toBe("80px");
|
||||
expect(panel.style.top).toBe("90px");
|
||||
expect(localStorage.getItem(key)).toBeNull();
|
||||
});
|
||||
|
||||
it("continues persistence at sheet width when suspension is not opted in", () => {
|
||||
const key = "floating-window:sheet-default";
|
||||
const geometry = { size: { width: 610, height: 440 }, position: { x: 90, y: 72 } };
|
||||
@@ -1000,8 +1045,122 @@ describe("FloatingWindow", () => {
|
||||
window.removeEventListener(FLOATING_WINDOW_GEOMETRY_CHANGE_EVENT, geometryEvents);
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:ModalTouchGeometry 2026-07-26-14:15:
|
||||
FN-8606 has thirteen modal identities but one geometry owner. Exercise every production class/key
|
||||
through the shared primitive so touch drag/resize, corrupt/off-screen restoration, persistence,
|
||||
and both sheet suspension breakpoints cannot silently diverge by caller identity.
|
||||
*/
|
||||
it.each(FN_8606_WINDOW_IDENTITIES)("keeps %s touch-moveable, resizable, clamped, and persisted", (_component, windowKey) => {
|
||||
const geometryKey = `floating-window:${windowKey}`;
|
||||
localStorage.setItem(geometryKey, JSON.stringify({
|
||||
size: { width: 99999, height: 99999 },
|
||||
position: { x: 99999, y: -99999 },
|
||||
}));
|
||||
|
||||
const { unmount } = render(
|
||||
<FloatingWindow
|
||||
windowKey={windowKey}
|
||||
title={windowKey}
|
||||
ariaLabel={`${windowKey} dialog`}
|
||||
onClose={() => {}}
|
||||
hideHeader
|
||||
dragHandleSelector=".migration-drag-handle"
|
||||
className={`floating-window--${windowKey}`}
|
||||
defaultSize={{ width: 500, height: 400 }}
|
||||
minSize={{ width: 360, height: 280 }}
|
||||
persistGeometryKey={geometryKey}
|
||||
suspendGeometryPersistenceOnMobile
|
||||
suspendGeometryPersistenceOnShortViewport
|
||||
>
|
||||
<div className="migration-drag-handle">Drag {windowKey}</div>
|
||||
</FloatingWindow>,
|
||||
);
|
||||
|
||||
const panel = expectFloatingWindowStructure(windowKey);
|
||||
expect(screen.getByTestId(`floating-window-overlay-${windowKey}`)).toHaveAttribute("aria-label", `${windowKey} dialog`);
|
||||
expect(Number.parseInt(panel.style.left, 10)).toBeGreaterThanOrEqual(16);
|
||||
expect(Number.parseInt(panel.style.top, 10)).toBeGreaterThanOrEqual(16);
|
||||
|
||||
dragWithTouch(screen.getByText(`Drag ${windowKey}`));
|
||||
resizeWithTouch(screen.getByTestId("floating-window-resize-se"));
|
||||
const persisted = JSON.parse(localStorage.getItem(geometryKey) ?? "{}");
|
||||
expect(persisted.position.x).toBeGreaterThanOrEqual(16);
|
||||
expect(persisted.position.y).toBeGreaterThanOrEqual(16);
|
||||
expect(persisted.size.width).toBeLessThanOrEqual(window.innerWidth - 32);
|
||||
expect(persisted.size.height).toBeLessThanOrEqual(window.innerHeight - 32);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it.each(FN_8606_WINDOW_IDENTITIES)("rejects corrupt persisted geometry for %s", (_component, windowKey) => {
|
||||
const geometryKey = `floating-window:${windowKey}`;
|
||||
localStorage.setItem(geometryKey, "not-json");
|
||||
render(
|
||||
<FloatingWindow
|
||||
windowKey={windowKey}
|
||||
title={windowKey}
|
||||
onClose={() => {}}
|
||||
defaultSize={{ width: 500, height: 400 }}
|
||||
persistGeometryKey={geometryKey}
|
||||
>
|
||||
<div>corrupt geometry fallback</div>
|
||||
</FloatingWindow>,
|
||||
);
|
||||
const panel = screen.getByTestId(`floating-window-${windowKey}`);
|
||||
expect(Number.parseInt(panel.style.width, 10)).toBe(500);
|
||||
});
|
||||
|
||||
it.each(FN_8606_WINDOW_IDENTITIES)("wires %s to its accessible shared-window identity", (component, windowKey) => {
|
||||
const source = readAppFile(`components/${component}`);
|
||||
expect(source).toContain(`windowKey=\"${windowKey}\"`);
|
||||
expect(source).toContain(`className=\"floating-window--${windowKey}\"`);
|
||||
expect(source).toContain(`persistGeometryKey=\"floating-window:${windowKey}\"`);
|
||||
expect(source).toContain("ariaLabel=");
|
||||
expect(source).toContain("suspendGeometryPersistenceOnMobile");
|
||||
expect(source).toContain("suspendGeometryPersistenceOnShortViewport");
|
||||
});
|
||||
|
||||
it.each(["phone", "short viewport"] as const)("suspends all FN-8606 geometry keys in %s sheet mode", (mode) => {
|
||||
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
|
||||
matches: mode === "phone" ? query === "(max-width: 767.98px)" : query === "(max-height: 480px)",
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
})));
|
||||
|
||||
for (const [, windowKey] of FN_8606_WINDOW_IDENTITIES) {
|
||||
const geometryKey = `floating-window:${windowKey}`;
|
||||
const { unmount } = render(
|
||||
<FloatingWindow
|
||||
windowKey={windowKey}
|
||||
title={windowKey}
|
||||
ariaLabel={`${windowKey} dialog`}
|
||||
onClose={() => {}}
|
||||
hideHeader
|
||||
dragHandleSelector=".migration-drag-handle"
|
||||
className={`floating-window--${windowKey}`}
|
||||
persistGeometryKey={geometryKey}
|
||||
suspendGeometryPersistenceOnMobile
|
||||
suspendGeometryPersistenceOnShortViewport
|
||||
>
|
||||
<div className="migration-drag-handle">Drag {windowKey}</div>
|
||||
</FloatingWindow>,
|
||||
);
|
||||
expect(localStorage.getItem(geometryKey)).toBeNull();
|
||||
expect(screen.getByTestId(`floating-window-${windowKey}`)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("floating-window-resize-se")).not.toBeInTheDocument();
|
||||
unmount();
|
||||
}
|
||||
|
||||
const sheetBlock = mediaBlockFor(floatingWindowCss, "(max-width: 767.98px), (max-height: 480px)");
|
||||
for (const [, windowKey] of FN_8606_WINDOW_IDENTITIES) {
|
||||
expect(sheetBlock).toContain(`.floating-window--${windowKey}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("makes only the mobile chat floating window full-screen", () => {
|
||||
const mobileBlock = floatingWindowCss.match(/@media\s*\(max-width:\s*767\.98px\)\s*\{[\s\S]*?\.floating-window--chat \.chat-view\s*\{[\s\S]*?\n\}/)?.[0];
|
||||
const mobileBlock = floatingWindowCss.match(/@media\s*\(max-width:\s*767\.98px\),\s*\(max-height:\s*480px\)\s*\{[\s\S]*?\.floating-window--chat \.chat-view\s*\{[\s\S]*?\n\}/)?.[0];
|
||||
|
||||
expect(mobileBlock).toContain(".floating-window--chat");
|
||||
expect(mobileBlock).toContain("width: 100vw !important;");
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { GitManagerModal } from "../GitManagerModal";
|
||||
import { assertModalGeometryRecoveryAndSheetContracts, assertRenderedModalTouchGeometry } from "./floatingWindowMigration.test-helpers";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { loadAllAppCss } from "../../test/cssFixture";
|
||||
|
||||
@@ -20,8 +21,9 @@ const mockUseMobileKeyboard = vi.fn(() => ({
|
||||
|
||||
vi.mock("../../hooks/useViewportMode", () => ({
|
||||
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
|
||||
isFullScreenSheetViewport: () => false,
|
||||
isShortViewport: () => false,
|
||||
isFullScreenSheetViewport: () => window.matchMedia("(max-width: 767.98px)").matches,
|
||||
isShortViewport: () => window.matchMedia("(max-height: 480px)").matches,
|
||||
isTabletTouchViewport: () => false,
|
||||
getViewportMode: () => mockUseViewportMode(),
|
||||
isMobileViewport: () => mockUseViewportMode() === "mobile",
|
||||
useViewportMode: () => mockUseViewportMode(),
|
||||
@@ -381,14 +383,14 @@ describe("GitManagerModal", () => {
|
||||
});
|
||||
|
||||
it("renders git-manager overlay class hook for mobile fullscreen CSS", async () => {
|
||||
const { container } = render(
|
||||
const { baseElement } = render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Git Manager")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(container.querySelector(".modal-overlay.git-manager-modal-overlay")).toBeTruthy();
|
||||
expect(baseElement.querySelector(".floating-window--git-manager")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps the sidebar-launched Git Manager overlay transparent and click-through like Files", () => {
|
||||
@@ -412,14 +414,14 @@ describe("GitManagerModal", () => {
|
||||
keyboardOpen: true,
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
const { baseElement } = render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Git Manager")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const modal = container.querySelector(".modal.gm-modal") as HTMLElement;
|
||||
const modal = baseElement.querySelector(".modal.gm-modal") as HTMLElement;
|
||||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("240px");
|
||||
expect(modal.style.getPropertyValue("--vv-height")).toBe("620px");
|
||||
expect(modal.style.getPropertyValue("--vv-offset-top")).toBe("18px");
|
||||
@@ -698,7 +700,7 @@ describe("GitManagerModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps separate scroll containers for unstaged and staged file lists", async () => {
|
||||
it("keeps separate scroll baseElements for unstaged and staged file lists", async () => {
|
||||
(fetchFileChanges as any).mockResolvedValue([
|
||||
...Array.from({ length: 30 }, (_, index) => ({
|
||||
file: `src/unstaged-${index}.ts`,
|
||||
@@ -2189,7 +2191,7 @@ describe("GitManagerModal", () => {
|
||||
]);
|
||||
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(
|
||||
const { baseElement } = render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
@@ -2200,7 +2202,7 @@ describe("GitManagerModal", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const selectedOrigin = container.querySelector(".gm-remote-selector-item.selected");
|
||||
const selectedOrigin = baseElement.querySelector(".gm-remote-selector-item.selected");
|
||||
expect(selectedOrigin).toBeTruthy();
|
||||
expect(selectedOrigin?.textContent ?? "").toContain("origin");
|
||||
});
|
||||
@@ -2211,7 +2213,7 @@ describe("GitManagerModal", () => {
|
||||
await user.click(upstreamButton as HTMLElement);
|
||||
|
||||
await waitFor(() => {
|
||||
const selectedUpstream = container.querySelector(".gm-remote-selector-item.selected");
|
||||
const selectedUpstream = baseElement.querySelector(".gm-remote-selector-item.selected");
|
||||
expect(selectedUpstream).toBeTruthy();
|
||||
expect(selectedUpstream?.textContent ?? "").toContain("upstream");
|
||||
});
|
||||
@@ -3788,3 +3790,11 @@ describe("GitManagerModal", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("GitManagerModal floating geometry", () => {
|
||||
it("uses its production header for touch drag and resize", () => {
|
||||
render(<GitManagerModal isOpen onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />);
|
||||
assertRenderedModalTouchGeometry("git-manager", screen.getByTestId("floating-window-git-manager").querySelector(".modal-header") as HTMLElement);
|
||||
assertModalGeometryRecoveryAndSheetContracts("git-manager", () => render(<GitManagerModal isOpen onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { GroupTaskModal } from "../GroupTaskModal";
|
||||
import { assertModalGeometryRecoveryAndSheetContracts, assertRenderedModalTouchGeometry } from "./floatingWindowMigration.test-helpers";
|
||||
import { apiGetBranchGroup, apiPromoteBranchGroup, apiAbandonBranchGroup } from "../../api";
|
||||
|
||||
type SseSubscription = {
|
||||
@@ -82,6 +83,14 @@ describe("GroupTaskModal", () => {
|
||||
expect(onOpenMemberTask).toHaveBeenCalledWith("FN-1");
|
||||
});
|
||||
|
||||
it("uses the production group header for touch drag and resize", async () => {
|
||||
mockedGet.mockResolvedValue({ group: makeGroup() } as Awaited<ReturnType<typeof apiGetBranchGroup>>);
|
||||
render(<GroupTaskModal isOpen onClose={vi.fn()} groupId="BG-1" onOpenMemberTask={vi.fn()} />);
|
||||
await screen.findByText("feature/shared");
|
||||
assertRenderedModalTouchGeometry("group-task", screen.getByRole("heading", { name: "Branch Group BG-1" }).closest(".modal-header") as HTMLElement);
|
||||
assertModalGeometryRecoveryAndSheetContracts("group-task", () => render(<GroupTaskModal isOpen onClose={vi.fn()} groupId="BG-1" onOpenMemberTask={vi.fn()} />));
|
||||
});
|
||||
|
||||
it("hides promote controls until complete", async () => {
|
||||
mockedGet.mockResolvedValue({ group: makeGroup() } as Awaited<ReturnType<typeof apiGetBranchGroup>>);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, act, within } from "@testing-library/react";
|
||||
import { ModelOnboardingModal } from "../ModelOnboardingModal";
|
||||
import { assertModalGeometryRecoveryAndSheetContracts, assertRenderedModalTouchGeometry } from "./floatingWindowMigration.test-helpers";
|
||||
import type { AuthProvider } from "../../api";
|
||||
import { clearAuthToken } from "../../auth";
|
||||
import type { Task } from "@fusion/core";
|
||||
@@ -225,8 +226,8 @@ async function navigateToFirstTaskStep() {
|
||||
});
|
||||
}
|
||||
|
||||
function getProviderOrderInSection(container: HTMLElement): string[] {
|
||||
return Array.from(container.querySelectorAll<HTMLElement>("[data-testid^='onboarding-provider-card-']"))
|
||||
function getProviderOrderInSection(baseElement: HTMLElement): string[] {
|
||||
return Array.from(baseElement.querySelectorAll<HTMLElement>("[data-testid^='onboarding-provider-card-']"))
|
||||
.map((card) => card.dataset.testid?.replace("onboarding-provider-card-", "") ?? "")
|
||||
.filter(Boolean);
|
||||
}
|
||||
@@ -1425,14 +1426,14 @@ describe("ModelOnboardingModal", () => {
|
||||
],
|
||||
});
|
||||
|
||||
const { container } = render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
|
||||
const { baseElement } = render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Anthropic")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Verify the authenticated provider has the connected modifier class
|
||||
const connectedCards = container.querySelectorAll(".onboarding-provider-card--connected");
|
||||
const connectedCards = baseElement.querySelectorAll(".onboarding-provider-card--connected");
|
||||
expect(connectedCards.length).toBe(1);
|
||||
|
||||
// Verify the connected card contains Anthropic
|
||||
@@ -1440,7 +1441,7 @@ describe("ModelOnboardingModal", () => {
|
||||
expect(anthropicCard?.textContent?.includes("Anthropic")).toBe(true);
|
||||
|
||||
// Verify non-authenticated provider does not have the modifier
|
||||
const allCards = container.querySelectorAll(".onboarding-provider-card");
|
||||
const allCards = baseElement.querySelectorAll(".onboarding-provider-card");
|
||||
const connectedCardIds = Array.from(connectedCards).map((card) =>
|
||||
card.querySelector(".onboarding-provider-card__name")?.textContent
|
||||
);
|
||||
@@ -5143,4 +5144,11 @@ describe("Custom providers disclosure", () => {
|
||||
expect(mockFetchModels.mock.calls.length).toBeGreaterThan(modelCallsBefore);
|
||||
});
|
||||
});
|
||||
|
||||
it("uses its production header for touch drag and resize", async () => {
|
||||
render(<ModelOnboardingModal isOpen onClose={vi.fn()} onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
|
||||
await screen.findByTestId("floating-window-model-onboarding");
|
||||
assertRenderedModalTouchGeometry("model-onboarding", screen.getByTestId("floating-window-model-onboarding").querySelector(".model-onboarding-header") as HTMLElement);
|
||||
assertModalGeometryRecoveryAndSheetContracts("model-onboarding", () => render(<ModelOnboardingModal isOpen onClose={vi.fn()} onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { NodeDetailModal } from "../NodeDetailModal";
|
||||
import { assertModalGeometryRecoveryAndSheetContracts, assertRenderedModalTouchGeometry } from "./floatingWindowMigration.test-helpers";
|
||||
import type { DockerNodeConfig, ManagedDockerNodeInfo, NodeInfo, ProjectInfo } from "../../api";
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
@@ -206,3 +207,11 @@ describe("NodeDetailModal docker section", () => {
|
||||
expect(screen.getByText("Read-only")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("NodeDetailModal floating geometry", () => {
|
||||
it("uses its production header for touch drag and resize", () => {
|
||||
render(<NodeDetailModal {...baseProps} />);
|
||||
assertRenderedModalTouchGeometry("node-detail", screen.getByText("Node Details").closest(".modal-header") as HTMLElement);
|
||||
assertModalGeometryRecoveryAndSheetContracts("node-detail", () => render(<NodeDetailModal {...baseProps} />));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { PlanningModeModal } from "../PlanningModeModal";
|
||||
import { assertModalGeometryRecoveryAndSheetContracts, assertRenderedModalTouchGeometry } from "./floatingWindowMigration.test-helpers";
|
||||
import {
|
||||
mockStartPlanningStreaming,
|
||||
mockCreatePlanningDraft,
|
||||
@@ -105,8 +106,9 @@ vi.mock("../../hooks/useConfirm", () => ({
|
||||
|
||||
vi.mock("../../hooks/useViewportMode", () => ({
|
||||
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
|
||||
isFullScreenSheetViewport: () => false,
|
||||
isShortViewport: () => false,
|
||||
isFullScreenSheetViewport: () => window.matchMedia("(max-width: 767.98px)").matches,
|
||||
isShortViewport: () => window.matchMedia("(max-height: 480px)").matches,
|
||||
isTabletTouchViewport: () => false,
|
||||
useViewportMode: () => mockUseViewportMode(),
|
||||
getViewportMode: () => mockUseViewportMode(),
|
||||
isMobileViewport: () => mockUseViewportMode() === "mobile",
|
||||
@@ -185,6 +187,9 @@ describe("PlanningModeModal autosize", () => {
|
||||
});
|
||||
|
||||
it("grows initial planning textarea and caps at max", async () => {
|
||||
// This assertion owns the blank composer; do not let its draft debounce replace it with a
|
||||
// session view while verifying the textarea's height contract.
|
||||
mockCreatePlanningDraft.mockReturnValue(new Promise(() => {}));
|
||||
render(<PlanningModeModal isOpen={true} onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={mockTasks} />);
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/Build a user authentication/i) as HTMLTextAreaElement;
|
||||
@@ -199,20 +204,21 @@ describe("PlanningModeModal autosize", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await userEvent.type(textarea, "line 1\nline 2");
|
||||
fireEvent.change(textarea, { target: { value: "line 1\nline 2" } });
|
||||
await waitFor(() => {
|
||||
expect(Number.parseInt(textarea.style.height, 10)).toBeGreaterThanOrEqual(120);
|
||||
expect(Number.parseInt(textarea.style.height, 10)).toBeLessThanOrEqual(640);
|
||||
const renderedTextarea = screen.getByPlaceholderText(/Build a user authentication/i) as HTMLTextAreaElement;
|
||||
expect(Number.parseInt(renderedTextarea.style.height, 10)).toBeGreaterThanOrEqual(120);
|
||||
expect(Number.parseInt(renderedTextarea.style.height, 10)).toBeLessThanOrEqual(640);
|
||||
});
|
||||
|
||||
await userEvent.type(textarea, "\nline 3\nline 4\nline 5");
|
||||
fireEvent.change(screen.getByPlaceholderText(/Build a user authentication/i), { target: { value: "line 1\nline 2\nline 3\nline 4\nline 5" } });
|
||||
await waitFor(() => {
|
||||
expect(textarea.style.height).toBe("500px");
|
||||
expect((screen.getByPlaceholderText(/Build a user authentication/i) as HTMLTextAreaElement).style.height).toBe("500px");
|
||||
});
|
||||
|
||||
await userEvent.type(textarea, "\nline 6\nline 7");
|
||||
fireEvent.change(screen.getByPlaceholderText(/Build a user authentication/i), { target: { value: "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7" } });
|
||||
await waitFor(() => {
|
||||
expect(textarea.style.height).toBe("640px");
|
||||
expect((screen.getByPlaceholderText(/Build a user authentication/i) as HTMLTextAreaElement).style.height).toBe("640px");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -316,4 +322,10 @@ describe("PlanningModeModal autosize", () => {
|
||||
expect(screen.getByRole("button", { name: "Proceed with plan" })).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("planning-create-retry")).toBeNull();
|
||||
});
|
||||
|
||||
it("uses its production header for touch drag and resize", () => {
|
||||
render(<PlanningModeModal isOpen onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={mockTasks} />);
|
||||
assertRenderedModalTouchGeometry("planning-mode", screen.getByTestId("floating-window-planning-mode").querySelector(".modal-header") as HTMLElement);
|
||||
assertModalGeometryRecoveryAndSheetContracts("planning-mode", () => render(<PlanningModeModal isOpen onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={mockTasks} />));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { act, render, screen, fireEvent, waitFor } from "@testing-library/react"
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { ScheduledTasksModal } from "../ScheduledTasksModal";
|
||||
import { assertModalGeometryRecoveryAndSheetContracts, assertRenderedModalTouchGeometry } from "./floatingWindowMigration.test-helpers";
|
||||
import type { Routine } from "@fusion/core";
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
@@ -179,9 +180,9 @@ describe("ScheduledTasksModal", () => {
|
||||
const initialTop = Number.parseFloat(panel.style.top);
|
||||
|
||||
act(() => {
|
||||
fireEvent.pointerDown(header, { pointerId: 11, clientX: 120, clientY: 80 });
|
||||
fireEvent.pointerMove(panel, { pointerId: 11, clientX: 220, clientY: 140 });
|
||||
fireEvent.pointerUp(panel, { pointerId: 11, clientX: 220, clientY: 140 });
|
||||
fireEvent.pointerDown(header, { pointerId: 11, pointerType: "touch", clientX: 120, clientY: 80 });
|
||||
fireEvent.pointerMove(panel, { pointerId: 11, pointerType: "touch", clientX: 220, clientY: 140 });
|
||||
fireEvent.pointerUp(panel, { pointerId: 11, pointerType: "touch", clientX: 220, clientY: 140 });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -195,9 +196,9 @@ describe("ScheduledTasksModal", () => {
|
||||
const heightAfterDrag = Number.parseFloat(panel.style.height);
|
||||
|
||||
act(() => {
|
||||
fireEvent.pointerDown(resizeHandle, { pointerId: 12, clientX: 700, clientY: 600 });
|
||||
fireEvent.pointerMove(resizeHandle, { pointerId: 12, clientX: 760, clientY: 650 });
|
||||
fireEvent.pointerUp(resizeHandle, { pointerId: 12, clientX: 760, clientY: 650 });
|
||||
fireEvent.pointerDown(resizeHandle, { pointerId: 12, pointerType: "touch", clientX: 700, clientY: 600 });
|
||||
fireEvent.pointerMove(resizeHandle, { pointerId: 12, pointerType: "touch", clientX: 760, clientY: 650 });
|
||||
fireEvent.pointerUp(resizeHandle, { pointerId: 12, pointerType: "touch", clientX: 760, clientY: 650 });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -636,3 +637,11 @@ describe("ScheduledTasksModal", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ScheduledTasksModal floating geometry", () => {
|
||||
it("uses its production header for touch drag and resize", () => {
|
||||
render(<ScheduledTasksModal onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
assertRenderedModalTouchGeometry("automation", screen.getByText("Automations").closest(".automation-modal__drag-handle") as HTMLElement);
|
||||
assertModalGeometryRecoveryAndSheetContracts("automation", () => render(<ScheduledTasksModal onClose={vi.fn()} addToast={vi.fn()} />));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { ScriptsModal } from "../ScriptsModal";
|
||||
import { assertModalGeometryRecoveryAndSheetContracts, assertRenderedModalTouchGeometry } from "./floatingWindowMigration.test-helpers";
|
||||
import type { ScriptEntry } from "../../api";
|
||||
|
||||
const mockScripts: Record<string, string> = {
|
||||
@@ -30,16 +31,14 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("ScriptsModal", () => {
|
||||
it("has the 'open' class on the modal overlay when visible", async () => {
|
||||
it("renders inside its FloatingWindow when visible", async () => {
|
||||
vi.mocked(fetchScripts).mockResolvedValueOnce(mockScripts);
|
||||
|
||||
render(
|
||||
<ScriptsModal isOpen={true} onClose={onClose} addToast={addToast} onRunScript={onRunScript} />
|
||||
);
|
||||
|
||||
const overlay = screen.getByTestId("scripts-modal");
|
||||
expect(overlay.classList.contains("modal-overlay")).toBe(true);
|
||||
expect(overlay.classList.contains("open")).toBe(true);
|
||||
expect(screen.getByTestId("floating-window-scripts")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render when closed", () => {
|
||||
@@ -412,3 +411,11 @@ describe("ScriptsModal", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ScriptsModal floating geometry", () => {
|
||||
it("uses its production header for touch drag and resize", () => {
|
||||
render(<ScriptsModal isOpen onClose={onClose} addToast={addToast} onRunScript={onRunScript} />);
|
||||
assertRenderedModalTouchGeometry("scripts", screen.getByText("Scripts").closest(".modal-header") as HTMLElement);
|
||||
assertModalGeometryRecoveryAndSheetContracts("scripts", () => render(<ScriptsModal isOpen onClose={onClose} addToast={addToast} onRunScript={onRunScript} />));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user