diff --git a/.changeset/fn-8607-floating-modal-contract.md b/.changeset/fn-8607-floating-modal-contract.md new file mode 100644 index 0000000000..83ace30a22 --- /dev/null +++ b/.changeset/fn-8607-floating-modal-contract.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Make long-running dashboard agent, onboarding, and utility dialogs movable and resizable. +category: feature +dev: Uses FloatingWindow geometry keys and the modalFloatingWindowContract ratchet. diff --git a/.changeset/fn-8620-bespoke-geometry-modals.md b/.changeset/fn-8620-bespoke-geometry-modals.md new file mode 100644 index 0000000000..618a2a83c6 --- /dev/null +++ b/.changeset/fn-8620-bespoke-geometry-modals.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: New Task, right-dock pop-out, and floating terminal windows are moveable and resizable on tablets. +category: feature +dev: Replaced three bespoke drag/resize implementations with FloatingWindow. Legacy geometry key pairs reset once; Terminal docked mode remains unchanged. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index c82087d938..4fca3d15f9 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -104,6 +104,14 @@ Use `FloatingWindow` for a moveable and resizable dashboard surface rather than Core/workflow FloatingWindow modals use `persistGeometryKey="floating-window:"`: `automation` (Scheduled Tasks), `settings`, `git-manager`, `planning-mode`, `changes-diff`, `model-onboarding`, `activity-log`, `scripts`, `add-node`, `connect-node`, `node-detail`, `workflow-add-step`, and `group-task`. The former size-only `fusion:settings-modal-size`, `fusion:git-modal-size`, `fusion:planning-modal-size`, `fusion:changes-diff-modal-size`, and `fusion:model-onboarding-modal-size` keys are superseded by their matching complete geometry records. All of these windows suspend reading and writing geometry on phone and short (`max-height: 480px`) sheet viewports, so a desktop position never leaks into the sheet and a sheet never overwrites the desktop choice. + + +All non-trivial modals must use `FloatingWindow` with `hideHeader`, a modal-owned `dragHandleSelector`, a class name, sensible `defaultSize`/`minSize`, a stable `persistGeometryKey`, `suspendGeometryPersistenceOnMobile`, and `suspendGeometryPersistenceOnShortViewport`. Desktop drag and eight-direction resize remain active; only known tablet touch viewports (768px–1024px) enlarge active targets to at least 44px. Exactly 768px is tablet-class and persists geometry; 767px and below are full-screen sheets. A viewport at `max-height: 480px` is also a full-screen sheet regardless of width and never reads or writes geometry. + +`closeOnOutsidePointerDown` defaults to **off**. A modal that previously dismissed on its backdrop must pass it explicitly, while first-run/blocking flows must omit it. Preserve the existing focus, Escape, ARIA, close guard, and scroll-container behavior when hosting content. `AgentListModal`, `AgentImportModal`, `AgentGenerationModal`, `AgentOnboardingModal`, `ExperimentalAgentOnboardingModal`, `SetupWizardModal`, `NativeShellOnboardingModal`, `DockerNodeOnboardingModal`, `MailboxModal`, `MilestoneSliceInterviewModal`, and `SubtaskBreakdownModal` use this contract. `modalFloatingWindowContract.test.tsx` and `migratedModalFixtures.tsx` ratchet their host, geometry, and dismissal configuration. + +Static opt-outs are only brief single-decision alerts without reflowable content or long dwell time: `DuplicateWarningModal`, `AgentErrorDetailsModal`, `ModelSelectionModal`, `ReportModal`, `ResearchTaskActionModal`, `SettingsSyncConflictModal`, and `StashConflictModal`. Their focused acknowledgement or urgent-conflict semantics do not benefit from persistent movable geometry; additions require a documented inventory justification. + ## 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. @@ -2310,3 +2318,42 @@ Direct conversations can be organized with reusable tags. Open a conversation's ### Tablet touch modal resize Task Detail and New Task retain their desktop resize chrome, but tablet-class touch viewports expose a 44px `data-resize-hit-target` around resize controls. The target is enabled only by the shared tablet-touch viewport classifier; true-phone sheets and desktop coarse-pointer devices do not expose it. + +## Floating modal contract + +All non-trivial, reflowable dashboard dialogs use `FloatingWindow`; do not create a second drag, +resize, or viewport-classification implementation. A migration uses the dialog's existing header as +its drag handle and supplies `hideHeader`, `dragHandleSelector`, `className`, `defaultSize`, +`minSize`, `persistGeometryKey`, `suspendGeometryPersistenceOnMobile`, and +`suspendGeometryPersistenceOnShortViewport`. Former blocking dialogs also pass `modal`: it enables +the shared backdrop, `aria-modal` dialog semantics, and keyboard focus boundary while preserving +shared touch geometry. `closeOnOutsidePointerDown` defaults to **off**; a dialog that previously +closed from its backdrop must opt in explicitly, and blocking first-run flows must omit it. + +| Viewport | Drag and resize | Persistence | +| --- | --- | --- | +| Desktop (including coarse-pointer desktop), ≥1025px | Active desktop mouse drag and eight-direction resize; targets are unchanged | Persisted | +| Touch tablet, 768–1024px | Active touch drag and resize with ≥44px `data-resize-hit-target` controls | Persisted | +| True phone, ≤767px | Full-screen sheet; no active geometry affordance | Suspended | +| Any viewport ≤480px tall | Full-screen sheet; this overrides the width row | Suspended | + +Exactly **768px** is touch-tablet when the shared `isTabletTouchViewport` classifier identifies a +touch tablet; **767px** starts the true-phone sheet range. Do not use bare `@media (pointer: +coarse)`: desktop coarse-pointer hardware retains normal desktop controls, while only the shared +classifier activates enlarged tablet targets. Preserve focus, Escape, ARIA labels, existing scroll +containers, and each dialog's dismissal/confirmation behavior when moving its content to the host. + +`AgentListModal`, `AgentImportModal`, `AgentGenerationModal`, `AgentOnboardingModal`, +`ExperimentalAgentOnboardingModal`, `SetupWizardModal`, `NativeShellOnboardingModal`, +`DockerNodeOnboardingModal`, `MailboxModal`, `MilestoneSliceInterviewModal`, and +`SubtaskBreakdownModal` use this contract. Outside dismissal is explicit for Agent List/Import/ +Generation, Docker onboarding (guarded while submitting), Mailbox, Milestone/Slice Interview, and +Subtask Breakdown; the four onboarding flows remain blocking. + +Only transient, single-decision confirm/alert dialogs with no reflowable content or long dwell time +may remain static. The inventory opt-outs are `AgentErrorDetailsModal` (brief acknowledgement), +`ModelSelectionModal` (compact focused choice), `ReportModal` (brief reporting action), +`ResearchTaskActionModal` (bounded confirmation), `SettingsSyncConflictModal` (urgent conflict +choice), and `StashConflictModal` (urgent bounded recovery). The executable inventory is guarded by +`modalFloatingWindowContract.test.tsx`; `migratedModalFixtures.tsx` keeps every hosted surface in +per-modal geometry coverage. diff --git a/docs/dashboard-modal-inventory.md b/docs/dashboard-modal-inventory.md index da64a02f3b..161de933c3 100644 --- a/docs/dashboard-modal-inventory.md +++ b/docs/dashboard-modal-inventory.md @@ -74,13 +74,13 @@ modal intended to dismiss on outside pointer-down must opt in explicitly. | `ModelOnboardingModal.tsx` | A | `ModelOnboardingModal.tsx:2438` ``), headerless delegated drag host. | already migrated | FN-8620 | Docked mode is a presentation exception and retains its top-edge resize plus `fusion:terminal-docked-height-`; floating legacy pair superseded by `fusion:terminal-float-geometry-` (one-time reset). | — | | `WorkflowAddStepModal.tsx` | A | `WorkflowAddStepModal.tsx:144` ` void; @@ -158,14 +159,9 @@ export function AgentGenerationModal({ roleDescription.trim().length <= MAX_ROLE_LENGTH; return ( -
{ - if (e.target === e.currentTarget) handleCancel(); - }} - > -
+ {/* FNXC:ModalTouchGeometry 2026-07-26-16:07: Generation can dwell while AI responds; persist shared geometry and preserve overlay dismissal. */} +
-
+ ); } diff --git a/packages/dashboard/app/components/AgentImportModal.tsx b/packages/dashboard/app/components/AgentImportModal.tsx index 890c023914..e8a2b60877 100644 --- a/packages/dashboard/app/components/AgentImportModal.tsx +++ b/packages/dashboard/app/components/AgentImportModal.tsx @@ -5,6 +5,8 @@ import { Upload, FileText, CheckCircle, AlertTriangle, X, Loader2, FolderOpen, G import { fetchCompanies, type CompanyEntry } from "../api"; import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; +import { FloatingWindow } from "./FloatingWindow"; + export interface AgentImportModalProps { isOpen: boolean; onClose: () => void; @@ -469,7 +471,8 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi if (!isOpen) return null; return ( -
{ if (e.target === e.currentTarget) handleClose(); }}> + + {/* FNXC:ModalTouchGeometry 2026-07-26-16:07: Import mapping is reflowable, so FloatingWindow owns tablet touch geometry and retains prior outside dismissal. */}
{/* Header */}
@@ -981,6 +984,6 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi )}
-
+ ); } diff --git a/packages/dashboard/app/components/AgentListModal.tsx b/packages/dashboard/app/components/AgentListModal.tsx index c94720a320..0134f69932 100644 --- a/packages/dashboard/app/components/AgentListModal.tsx +++ b/packages/dashboard/app/components/AgentListModal.tsx @@ -17,6 +17,7 @@ import { AgentAvatar } from "./AgentAvatar"; import { AgentErrorIndicator } from "./AgentErrorDetailsModal"; import { AgentTaskBadge } from "./AgentTaskBadge"; +import { FloatingWindow } from "./FloatingWindow"; interface AgentListModalProps { isOpen: boolean; onClose: () => void; @@ -286,8 +287,9 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi if (!isOpen) return null; return ( -
e.target === e.currentTarget && onClose()} role="dialog" aria-modal="true"> -
+ + {/* FNXC:ModalTouchGeometry 2026-07-26-16:07: Agents is a long-lived workspace; shared geometry persists on desktop/tablet while explicit outside dismissal preserves its former overlay behavior. */} +

@@ -718,6 +720,6 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi

-
+ ); } diff --git a/packages/dashboard/app/components/AgentOnboardingModal.tsx b/packages/dashboard/app/components/AgentOnboardingModal.tsx index 4b359a9f4f..c116bad367 100644 --- a/packages/dashboard/app/components/AgentOnboardingModal.tsx +++ b/packages/dashboard/app/components/AgentOnboardingModal.tsx @@ -23,6 +23,7 @@ import { useAutosizeTextarea } from "../hooks/useAutosizeTextarea"; type ViewState = "initial" | "loading" | "question" | "summary" | "creating" | "error"; +import { FloatingWindow } from "./FloatingWindow"; interface AgentOnboardingModalProps { isOpen: boolean; onClose: () => void; @@ -191,7 +192,8 @@ export function AgentOnboardingModal({ isOpen, onClose, onCreated, addToast, pro }; return ( -
+ void handleClose()} hideHeader dragHandleSelector=".agent-onboarding-modal .modal-header" className="floating-window--agent-onboarding" defaultSize={{ width: 720, height: 620 }} minSize={{ width: 420, height: 320 }} persistGeometryKey="floating-window:agent-onboarding" suspendGeometryPersistenceOnMobile suspendGeometryPersistenceOnShortViewport modal> + {/* FNXC:ModalTouchGeometry 2026-07-26-16:07: First-run onboarding remains blocking: omit outside dismissal while sharing tablet geometry and suspending every sheet viewport. */}

{t("agents.onboarding.title", "Agent Onboarding")}

@@ -277,6 +279,6 @@ export function AgentOnboardingModal({ isOpen, onClose, onCreated, addToast, pro
)}
-
+ ); } diff --git a/packages/dashboard/app/components/DockerNodeOnboardingModal.tsx b/packages/dashboard/app/components/DockerNodeOnboardingModal.tsx index 2d0d3d2e10..522fe25fa2 100644 --- a/packages/dashboard/app/components/DockerNodeOnboardingModal.tsx +++ b/packages/dashboard/app/components/DockerNodeOnboardingModal.tsx @@ -5,6 +5,7 @@ import type { DockerHostConfig, ManagedDockerNodeInput } from "@fusion/core"; import type { ToastType } from "../hooks/useToast"; import { DockerTargetSelector } from "./DockerTargetSelector"; import "./DockerNodeOnboardingModal.css"; +import { FloatingWindow } from "./FloatingWindow"; interface DockerNodeOnboardingModalProps { isOpen: boolean; @@ -211,14 +212,9 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast: if (!isOpen) return null; return ( -
-
event.stopPropagation()} - > + { if (!submitting) closeModal(); }} hideHeader dragHandleSelector=".docker-onboarding .modal-header" className="floating-window--docker-node-onboarding" defaultSize={{ width: 720, height: 640 }} minSize={{ width: 420, height: 320 }} persistGeometryKey="floating-window:docker-node-onboarding" suspendGeometryPersistenceOnMobile suspendGeometryPersistenceOnShortViewport closeOnOutsidePointerDown modal> + {/* FNXC:ModalTouchGeometry 2026-07-26-16:22: Provisioning retains outside dismissal only before submit; the guarded host close protects an in-flight node creation. */} +

{t("docker.titles.provisionNode", "Provision Docker Node")}

-
+ ); } diff --git a/packages/dashboard/app/components/ExperimentalAgentOnboardingModal.tsx b/packages/dashboard/app/components/ExperimentalAgentOnboardingModal.tsx index 296672dac7..d9bc54822e 100644 --- a/packages/dashboard/app/components/ExperimentalAgentOnboardingModal.tsx +++ b/packages/dashboard/app/components/ExperimentalAgentOnboardingModal.tsx @@ -13,6 +13,7 @@ import "./ExperimentalAgentOnboardingModal.css"; type ViewState = "initial" | "loading" | "question" | "summary" | "error"; +import { FloatingWindow } from "./FloatingWindow"; interface ExperimentalAgentOnboardingModalProps { isOpen: boolean; onClose: () => void; @@ -184,7 +185,8 @@ export function ExperimentalAgentOnboardingModal({ }; return ( -
+ void handleClose()} hideHeader dragHandleSelector=".experimental-agent-onboarding-modal .modal-header" className="floating-window--experimental-agent-onboarding" defaultSize={{ width: 720, height: 620 }} minSize={{ width: 420, height: 320 }} persistGeometryKey="floating-window:experimental-agent-onboarding" suspendGeometryPersistenceOnMobile suspendGeometryPersistenceOnShortViewport modal> + {/* FNXC:ModalTouchGeometry 2026-07-26-16:07: Experimental onboarding remains a blocking flow; no outside-dismiss opt-in accompanies shared geometry. */}

{t("agents.onboarding.title", "AI Interview")}

@@ -283,6 +285,6 @@ export function ExperimentalAgentOnboardingModal({
)}
-
+ ); } diff --git a/packages/dashboard/app/components/FloatingWindow.css b/packages/dashboard/app/components/FloatingWindow.css index 5c37eebd4f..c1a669ab12 100644 --- a/packages/dashboard/app/components/FloatingWindow.css +++ b/packages/dashboard/app/components/FloatingWindow.css @@ -20,6 +20,15 @@ sheet in the layout tree. `visibility: hidden` removes paint while preserving ch message-list scroll state; `pointer-events: none` keeps the already click-through overlay inert. Never replace this with `display: none`, which collapses layout and makes reopening visibly reflow. */ +/* +FNXC:ModalTouchGeometry 2026-07-26-18:42: +FN-8607's migrated dialogs remain blocking despite sharing FloatingWindow with non-blocking +utilities. This opt-in backdrop prevents pointer events reaching the application beneath a modal. +*/ +.floating-window-overlay--modal { + pointer-events: auto; +} + .floating-window-overlay--hidden { visibility: hidden; pointer-events: none; @@ -298,6 +307,15 @@ all floating affordances so persisted desktop geometry cannot imply a draggable } /* 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. */ + /* + FNXC:ModalTouchGeometry 2026-07-27-20:00: + FN-8620's New Task, right-dock, and terminal hosts use the same short/phone sheet contract. + Their content owns custom chrome, but the shared host must fill the viewport when geometry is + suspended so hiding handles does not leave a stranded desktop-sized panel. + */ + .floating-window.new-task-modal, + .floating-window.right-dock-expand-modal--floating, + .floating-window.terminal-modal--floating, .floating-window--automation, .floating-window--activity-log, .floating-window--scripts, @@ -324,6 +342,9 @@ all floating affordances so persisted desktop geometry cannot imply a draggable box-shadow: none; } + .floating-window.new-task-modal .floating-window__resize-handle, + .floating-window.right-dock-expand-modal--floating .floating-window__resize-handle, + .floating-window.terminal-modal--floating .floating-window__resize-handle, .floating-window--automation .floating-window__resize-handle, .floating-window--activity-log .floating-window__resize-handle, .floating-window--scripts .floating-window__resize-handle, diff --git a/packages/dashboard/app/components/FloatingWindow.tsx b/packages/dashboard/app/components/FloatingWindow.tsx index 1e441e65c3..e7dce975a3 100644 --- a/packages/dashboard/app/components/FloatingWindow.tsx +++ b/packages/dashboard/app/components/FloatingWindow.tsx @@ -58,6 +58,10 @@ export interface FloatingWindowProps { * Persistent task/terminal pop-outs must omit this so page clicks do not close them. */ closeOnOutsidePointerDown?: boolean; + /** Render as a blocking dialog instead of the default coexisting utility window. */ + modal?: boolean; + /** Optional legacy hook for callers whose overlay is asserted by existing tests. */ + testId?: string; /* FNXC:FloatingWindow 2026-07-18-00:00: Quick Chat must hide without unmounting so its active session, messages, and scroll position @@ -199,6 +203,8 @@ export function FloatingWindow({ suspendGeometryPersistenceOnMobile = false, suspendGeometryPersistenceOnShortViewport = false, closeOnOutsidePointerDown = false, + modal = false, + testId, hidden = false, layer = "utility", ariaLabel, @@ -236,6 +242,29 @@ export function FloatingWindow({ initialGeometry.current!.size ); const [position, setPosition] = useState(() => initialGeometry.current!.position); + const geometryIdentityRef = useRef({ windowKey, persistGeometryKey }); + + /* + FNXC:ModalTouchGeometry 2026-07-27-20:00: + A project-scoped floating host can stay mounted while its window/storage identity changes. + Reload that identity's geometry before passive persistence runs so Terminal never copies one + project's geometry into another project's key. + */ + useLayoutEffect(() => { + const previousIdentity = geometryIdentityRef.current; + if (previousIdentity.windowKey === windowKey && previousIdentity.persistGeometryKey === persistGeometryKey) return; + + const fallbackSize = clampSize(defaultSize ?? { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT }, resolvedMinSize); + const fallbackPosition = defaultPosition ? clampPosition(defaultPosition, fallbackSize) : defaultPositionFor(windowKey, fallbackSize); + const nextGeometry = geometryPersistenceSuspended + ? { size: fallbackSize, position: fallbackPosition } + : readPersistedGeometry(persistGeometryKey, fallbackSize, fallbackPosition, resolvedMinSize); + geometryIdentityRef.current = { windowKey, persistGeometryKey }; + initialGeometry.current = nextGeometry; + setSize(nextGeometry.size); + setPosition(nextGeometry.position); + }, [defaultPosition, defaultSize, geometryPersistenceSuspended, persistGeometryKey, resolvedMinSize, windowKey]); + const claimFrontZ = useCallback(() => (layer === "task-detail" ? nextTaskDetailFloatingZ() : nextFloatingZ()), [layer]); const readCurrentZ = useCallback(() => (layer === "task-detail" ? currentTaskDetailFloatingZ() : currentFloatingZ()), [layer]); /* @@ -524,6 +553,32 @@ export function FloatingWindow({ } }, [geometryPersistenceSuspended, hidden, persistGeometryKey, position, size]); + /* + FNXC:ModalTouchGeometry 2026-07-26-18:42: + FN-8607 migrates former blocking dialogs into the shared geometry host. Modal callers opt into + a real backdrop and keyboard focus boundary; utility windows retain the historical click-through + behavior by default so this does not change existing multi-window surfaces. + */ + useEffect(() => { + if (!modal || hidden || typeof document === "undefined") return; + const panel = panelRef.current; + const priorFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; + panel?.focus(); + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Tab" || !panel) return; + const focusable = Array.from(panel.querySelectorAll( + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', + )).filter((element) => !element.hasAttribute("hidden")); + if (focusable.length === 0) { event.preventDefault(); panel.focus(); return; } + const current = document.activeElement; + const index = focusable.indexOf(current as HTMLElement); + if (event.shiftKey && (index <= 0 || !panel.contains(current))) { event.preventDefault(); focusable.at(-1)?.focus(); } + else if (!event.shiftKey && index === focusable.length - 1) { event.preventDefault(); focusable[0]?.focus(); } + }; + document.addEventListener("keydown", onKeyDown); + return () => { document.removeEventListener("keydown", onKeyDown); priorFocus?.focus(); }; + }, [hidden, modal]); + const panelStyle = { left: `${position.x}px`, top: `${position.y}px`, @@ -544,13 +599,13 @@ export function FloatingWindow({ */ return createPortal(
@@ -562,6 +617,7 @@ export function FloatingWindow({ onPointerDownCapture={bringToFront} onPointerDown={handlePanelPointerDown} onFocusCapture={bringToFront} + tabIndex={modal ? -1 : undefined} > {/* FNXC:ModalTouchGeometry 2026-07-26-16:54: diff --git a/packages/dashboard/app/components/MailboxModal.tsx b/packages/dashboard/app/components/MailboxModal.tsx index c9b932067e..9b1fdc26bd 100644 --- a/packages/dashboard/app/components/MailboxModal.tsx +++ b/packages/dashboard/app/components/MailboxModal.tsx @@ -1,4 +1,5 @@ import "./MailboxModal.css"; +import { FloatingWindow } from "./FloatingWindow"; import { useState, useEffect, useCallback, useMemo, useRef, type CSSProperties } from "react"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; @@ -767,15 +768,8 @@ export function MailboxModal({ // ── Render ──────────────────────────────────────────────────────────── return ( -
{ - if (e.target === e.currentTarget) onClose(); - }} - role="dialog" - aria-modal="true" - data-testid="mailbox-modal-overlay" - > + + {/* FNXC:ModalTouchGeometry 2026-07-26-16:22: Mailbox is a long-lived workspace; preserve outside dismissal and keep keyboard positioning inside the hosted panel. */}
{/* Header */}
@@ -1265,7 +1259,7 @@ export function MailboxModal({
-
+ ); } diff --git a/packages/dashboard/app/components/MilestoneSliceInterviewModal.tsx b/packages/dashboard/app/components/MilestoneSliceInterviewModal.tsx index 66a529f74e..18879ec6cc 100644 --- a/packages/dashboard/app/components/MilestoneSliceInterviewModal.tsx +++ b/packages/dashboard/app/components/MilestoneSliceInterviewModal.tsx @@ -33,6 +33,7 @@ import { useAiSessionSync } from "../hooks/useAiSessionSync"; import { useMobileKeyboard } from "../hooks/useMobileKeyboard"; import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; import { useViewportMode } from "../hooks/useViewportMode"; +import { FloatingWindow } from "./FloatingWindow"; const WARNING_ICON = "⚠️"; const MILESTONE_SLICE_OTHER_RESPONSE_KEY = "_other"; @@ -406,13 +407,8 @@ export function MilestoneSliceInterviewModal({ if (!isOpen) return null; return ( -
e.target === e.currentTarget && handleCancel()} - role="dialog" - aria-modal="true" - data-testid="milestone-slice-interview-modal" - > + + {/* FNXC:ModalTouchGeometry 2026-07-26-16:22: Interview dismissal still routes through handleCancel so confirmation semantics survive shared tablet geometry. */}
@@ -572,7 +568,7 @@ export function MilestoneSliceInterviewModal({ )}
-
+
); } diff --git a/packages/dashboard/app/components/NativeShellOnboardingModal.tsx b/packages/dashboard/app/components/NativeShellOnboardingModal.tsx index d7b23d36fe..ce4b984b04 100644 --- a/packages/dashboard/app/components/NativeShellOnboardingModal.tsx +++ b/packages/dashboard/app/components/NativeShellOnboardingModal.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import type { FusionShellApi, ShellConnectionState } from "../types/native-shell"; import "./NativeShellOnboardingModal.css"; +import { FloatingWindow } from "./FloatingWindow"; function buildRemoteDashboardUrl(serverUrl: string, authToken?: string | null): string { const url = new URL(serverUrl); @@ -45,7 +46,8 @@ export function NativeShellOnboardingModal({ open, shellApi, shellState, onCompl } return ( -
+ {}} hideHeader dragHandleSelector=".native-shell-onboarding-modal .modal-header" className="floating-window--native-shell-onboarding" defaultSize={{ width: 640, height: 560 }} minSize={{ width: 400, height: 320 }} persistGeometryKey="floating-window:native-shell-onboarding" suspendGeometryPersistenceOnMobile suspendGeometryPersistenceOnShortViewport modal> + {/* FNXC:ModalTouchGeometry 2026-07-26-16:22: Connection onboarding is blocking, so its shared geometry deliberately has no outside or Escape dismissal. */}

{t("onboarding.welcome", "Welcome to Fusion")}

@@ -133,6 +135,6 @@ export function NativeShellOnboardingModal({ open, shellApi, shellState, onCompl
-
+ ); } diff --git a/packages/dashboard/app/components/NewTaskModal.css b/packages/dashboard/app/components/NewTaskModal.css index 0911cd53b6..023dbf58be 100644 --- a/packages/dashboard/app/components/NewTaskModal.css +++ b/packages/dashboard/app/components/NewTaskModal.css @@ -1,7 +1,8 @@ /* === New Task Modal === */ /* -FNXC:NewTaskMobileAffordances 2026-06-25-12:22: -The transparent New Task overlay intentionally passes clicks through for desktop floating mode, so the panel itself must always opt back into hit testing. Mobile uses the non-floating full-screen sheet and must keep every desktop affordance (GitHub picker, quick buttons, dropdowns, and action row) tappable and scroll-reachable even when the keyboard constrains the visual viewport. +FNXC:ModalTouchGeometry 2026-07-27-18:00: +FN-8620 removes New Task's local floating geometry CSS. FloatingWindow owns its shared drag, +resize, sheet, and tablet touch-target presentation; this component retains only form layout. */ .new-task-modal { min-height: min(520px, 80vh); @@ -9,196 +10,12 @@ The transparent New Task overlay intentionally passes clicks through for desktop } /* -FNXC:NewTask 2026-06-22-20:30: -The New Task dialog is a FLOATING, DRAGGABLE, RESIZABLE, NON-BLOCKING window (mirrors the right-dock pop-out). The overlay MUST out-specify the base `.modal-overlay` (which dims + blurs the page). Both base and override are single-class, so a two-class selector (`.modal-overlay.new-task-modal-overlay`) guarantees the transparent, non-blurring, click-through backdrop regardless of stylesheet order. `pointer-events: none` lets behind-clicks pass through to the app; the floating panel re-enables `pointer-events: auto`. No overlay click-to-dismiss — the header X / Cancel / Escape are the only dismissals. -*/ -.modal-overlay.new-task-modal-overlay { - align-items: stretch; - justify-content: flex-start; - padding: 0; - background: transparent; - backdrop-filter: none; - pointer-events: none; -} - -/* -FNXC:FloatingWindow 2026-06-22-21:30: -Only the desktop FLOATING New Task dialog joins the shared cross-type floating stack. When the overlay hosts the floating panel, reset the base `.modal-overlay` z-index:100 to auto so it does NOT establish a stacking context; the panel's inline z-index (from floatingWindowStack, 4000+) then interleaves at the root with the terminal, the right-dock pop-out, and FloatingWindow. The mobile full-screen sheet (no `--floating` panel) keeps the base overlay z-index:100 so it still paints above page content. -*/ -.modal-overlay.new-task-modal-overlay:has(.new-task-modal--floating) { - z-index: auto; -} - -/* -FNXC:NewTask 2026-06-22-20:30: -Floating panel positioned by state-driven inline left/top/width/height. min/max keep content usable and the panel on-screen; `resize: none` because the corner/edge handles own resizing (the native grip conflicts with the pointer handlers). `pointer-events: auto` re-enables interaction on the panel only. Desktop only — mobile keeps the full-screen keyboard-aware sheet. -*/ -/* -FNXC:TaskModalResize 2026-08-07-00:00: -The floating class is also applied to known tablets at the 768px CSS boundary. -Keep this selector more specific than the global phone-sheet rule; the runtime -viewport contract, not a duplicate CSS breakpoint, decides whether it applies. -*/ -.modal.new-task-modal.new-task-modal--floating { - --floating-window-shadow: var(--shadow-lg); - position: fixed; - display: flex; - flex-direction: column; - min-width: calc(var(--space-2xl) * 8.75); - min-height: calc(var(--space-2xl) * 7.5); - max-width: calc(100vw - (var(--space-lg) * 2)); - max-height: calc(100dvh - (var(--space-lg) * 2)); - resize: none; - pointer-events: auto; - /* - FNXC:FloatingWindow 2026-06-23-23:32: - Floating modals share a gentle theme-controlled elevation token. Use the app shadow fallback instead of undefined --shadow-xl so themes can opt out or tune shadow strength consistently across New Task, Terminal, Right Dock, and shared FloatingWindow panels. - */ - box-shadow: var(--floating-window-shadow, var(--shadow-lg)); -} - -.new-task-modal--floating .modal-body { - max-height: none; -} - -/* -FNXC:NewTask 2026-06-22-20:30: -Header is the drag handle. `touch-action: none` (matching the resize handles) hands the whole gesture to our pointer handlers so a finger drag stays smooth and never scrolls the page behind it. `cursor: grab/grabbing` is desktop-only signal. +FNXC:ModalTouchGeometry 2026-07-27-20:05: +The custom New Task header remains FloatingWindow's delegated drag surface. Keep its gesture +contract explicit so tablet touch reaches the shared captured-pointer handler rather than page pan. */ .new-task-modal__header--draggable { - cursor: grab; - user-select: none; touch-action: none; - min-height: 48px; -} - -.new-task-modal__header--draggable:active { - cursor: grabbing; -} - -/* -FNXC:NewTask 2026-06-22-20:30: -Edge + corner resize handles. touch-action:none keeps the drag from being hijacked by scroll/gestures so resizing stays smooth. -*/ -/* -FNXC:TaskModalResize 2026-07-24-19:00: -Resize grips are focusable separators on floating desktop and tablet task windows. Preserve a -visible tokenized focus ring so keyboard users can discover the same controls touch users drag. -*/ -.new-task-resize-handle { - position: absolute; - z-index: 2; - touch-action: none; -} - -.new-task-resize-handle:focus-visible { - outline: none; - box-shadow: var(--focus-ring-strong); -} - -.new-task-resize-handle--n, -.new-task-resize-handle--s { - left: var(--space-sm); - right: var(--space-sm); - height: var(--space-sm); - cursor: ns-resize; -} - -.new-task-resize-handle--n { top: 0; } -.new-task-resize-handle--s { bottom: 0; } - -.new-task-resize-handle--e, -.new-task-resize-handle--w { - top: var(--space-sm); - bottom: var(--space-sm); - width: var(--space-sm); - cursor: ew-resize; -} - -.new-task-resize-handle--e { right: 0; } -.new-task-resize-handle--w { left: 0; } - -.new-task-resize-handle--ne, -.new-task-resize-handle--nw, -.new-task-resize-handle--se, -.new-task-resize-handle--sw { - width: var(--space-lg); - height: var(--space-lg); -} - -.new-task-resize-handle--ne { top: 0; right: 0; cursor: nesw-resize; } -.new-task-resize-handle--nw { top: 0; left: 0; cursor: nwse-resize; } -.new-task-resize-handle--se { bottom: 0; right: 0; cursor: nwse-resize; } -.new-task-resize-handle--sw { bottom: 0; left: 0; cursor: nesw-resize; } - -/* -FNXC:TaskModalResize 2026-07-26-10:40: -Finger drags need a 44px hit area, unlike the mouse-sized painted edge/corner controls. -These targets expand outward from the floating panel so they cannot cover its footer or -scrollable body. The runtime tablet-touch class is required: true-phone sheets and desktop -coarse-pointer devices must never expose an invisible resize target. -*/ -.task-modal--touch-resize .new-task-resize-handle--touch-target { - z-index: 3; - touch-action: none; -} - -.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--n, -.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--s { - left: var(--modal-resize-touch-target); - right: var(--modal-resize-touch-target); - height: var(--modal-resize-touch-target); -} - -.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--n { - top: calc((var(--modal-resize-touch-target) - var(--space-sm)) * -1); -} - -.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--s { - bottom: calc((var(--modal-resize-touch-target) - var(--space-sm)) * -1); -} - -.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--e, -.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--w { - top: var(--modal-resize-touch-target); - bottom: var(--modal-resize-touch-target); - width: var(--modal-resize-touch-target); -} - -.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--e { - right: calc((var(--modal-resize-touch-target) - var(--space-sm)) * -1); -} - -.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--w { - left: calc((var(--modal-resize-touch-target) - var(--space-sm)) * -1); -} - -.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--ne, -.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--nw, -.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--se, -.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--sw { - width: var(--modal-resize-touch-target); - height: var(--modal-resize-touch-target); -} - -.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--ne { - top: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1); - right: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1); -} - -.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--nw { - top: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1); - left: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1); -} - -.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--se { - bottom: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1); - right: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1); -} - -.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--sw { - bottom: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1); - left: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1); } .new-task-modal .modal-body { @@ -208,7 +25,6 @@ coarse-pointer devices must never expose an invisible resize target. flex: 1; min-height: 0; } - .new-task-modal .form-group { margin-top: 0; margin-bottom: var(--space-xl); diff --git a/packages/dashboard/app/components/NewTaskModal.tsx b/packages/dashboard/app/components/NewTaskModal.tsx index 6403a5d259..a36636fe67 100644 --- a/packages/dashboard/app/components/NewTaskModal.tsx +++ b/packages/dashboard/app/components/NewTaskModal.tsx @@ -1,5 +1,5 @@ import "./NewTaskModal.css"; -import { useState, useCallback, useEffect, useRef, type CSSProperties, type ChangeEvent, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent } from "react"; +import { useState, useCallback, useEffect, useRef, type ChangeEvent } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { DEFAULT_TASK_PRIORITY, type Task, type TaskPriority } from "@fusion/core"; @@ -28,9 +28,9 @@ import { useConfirm } from "../hooks/useConfirm"; import { useMobileKeyboard } from "../hooks/useMobileKeyboard"; import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; import { useNodes } from "../hooks/useNodes"; -import { isTabletTouchViewport, useViewportMode } from "../hooks/useViewportMode"; +import { useViewportMode } from "../hooks/useViewportMode"; import { useAgentsMapCache } from "../hooks/useAgentsMapCache"; -import { nextFloatingZ, currentFloatingZ } from "./floatingWindowStack"; +import { FloatingWindow } from "./FloatingWindow"; type NewTaskCreateInput = Omit & { branchSelection?: { @@ -54,100 +54,15 @@ interface NewTaskModalProps { } /* -FNXC:NewTask 2026-06-22-20:30: -The New Task dialog is a FLOATING, DRAGGABLE, RESIZABLE, NON-BLOCKING window matching the right-dock pop-out (RightDockExpandModal). The overlay is transparent and `pointer-events: none` so the app behind stays usable and behind-clicks pass through — there is therefore NO overlay click-to-dismiss; the header close (X) and Cancel button are the only dismissals (plus Escape). The panel is `position: fixed; pointer-events: auto`, dragged by its header and resized from corner/edge handles, with rAF-batched position/size state and a single teardown ref invoked on pointerup/pointercancel AND on unmount so no document/element listeners or pending rAF leak. Size/position persist to localStorage. On mobile we keep the full-screen sheet behavior (no floating) so the keyboard-aware layout still works. +FNXC:ModalTouchGeometry 2026-07-27-18:00: +FN-8620 replaces New Task's bespoke pointer geometry with FloatingWindow. The single shared +geometry key intentionally supersedes the former size/position pair; old values reset once. */ -const NEW_TASK_MODAL_SIZE_STORAGE_KEY = "fusion:new-task-modal-size"; -const NEW_TASK_MODAL_POSITION_STORAGE_KEY = "fusion:new-task-modal-position"; - const NEW_TASK_DEFAULT_WIDTH = 720; const NEW_TASK_DEFAULT_HEIGHT = 640; const NEW_TASK_MIN_WIDTH = 420; const NEW_TASK_MIN_HEIGHT = 360; -const NEW_TASK_VIEWPORT_PADDING = 16; -/* -FNXC:TaskModalResize 2026-07-24-19:00: -Keyboard resizing follows the existing viewport padding quantum, so focusable edge controls produce predictable, touch-safe geometry changes without a second sizing scale. -*/ -const NEW_TASK_KEYBOARD_RESIZE_STEP = NEW_TASK_VIEWPORT_PADDING; -interface FloatSize { - width: number; - height: number; -} - -interface FloatPosition { - x: number; - y: number; -} - -function clampFloatSize(size: FloatSize): FloatSize { - if (typeof window === "undefined") return size; - return { - width: Math.min(Math.max(size.width, NEW_TASK_MIN_WIDTH), Math.max(NEW_TASK_MIN_WIDTH, window.innerWidth - NEW_TASK_VIEWPORT_PADDING * 2)), - height: Math.min(Math.max(size.height, NEW_TASK_MIN_HEIGHT), Math.max(NEW_TASK_MIN_HEIGHT, window.innerHeight - NEW_TASK_VIEWPORT_PADDING * 2)), - }; -} - -function clampFloatPosition(position: FloatPosition, size: FloatSize): FloatPosition { - if (typeof window === "undefined") return position; - return { - x: Math.min(Math.max(position.x, NEW_TASK_VIEWPORT_PADDING), Math.max(NEW_TASK_VIEWPORT_PADDING, window.innerWidth - size.width - NEW_TASK_VIEWPORT_PADDING)), - y: Math.min(Math.max(position.y, NEW_TASK_VIEWPORT_PADDING), Math.max(NEW_TASK_VIEWPORT_PADDING, window.innerHeight - size.height - NEW_TASK_VIEWPORT_PADDING)), - }; -} - -function readFloatSize(): FloatSize { - if (typeof window === "undefined") return { width: NEW_TASK_DEFAULT_WIDTH, height: NEW_TASK_DEFAULT_HEIGHT }; - try { - const raw = window.localStorage.getItem(NEW_TASK_MODAL_SIZE_STORAGE_KEY); - if (raw) { - const parsed = JSON.parse(raw) as Partial; - if (typeof parsed.width === "number" && typeof parsed.height === "number") { - return clampFloatSize({ width: parsed.width, height: parsed.height }); - } - } - } catch { - // ignore corrupted persisted size - } - return clampFloatSize({ width: NEW_TASK_DEFAULT_WIDTH, height: NEW_TASK_DEFAULT_HEIGHT }); -} - -function writeFloatSize(size: FloatSize): FloatSize { - const clamped = clampFloatSize(size); - if (typeof window !== "undefined") { - window.localStorage.setItem(NEW_TASK_MODAL_SIZE_STORAGE_KEY, JSON.stringify(clamped)); - } - return clamped; -} - -function readFloatPosition(size: FloatSize): FloatPosition { - if (typeof window === "undefined") return { x: NEW_TASK_VIEWPORT_PADDING, y: NEW_TASK_VIEWPORT_PADDING }; - try { - const raw = window.localStorage.getItem(NEW_TASK_MODAL_POSITION_STORAGE_KEY); - if (raw) { - const parsed = JSON.parse(raw) as Partial; - if (typeof parsed.x === "number" && typeof parsed.y === "number") { - return clampFloatPosition({ x: parsed.x, y: parsed.y }, size); - } - } - } catch { - // ignore corrupted persisted position - } - // Default: roughly centered. - return clampFloatPosition({ x: (window.innerWidth - size.width) / 2, y: (window.innerHeight - size.height) / 2 }, size); -} - -function writeFloatPosition(position: FloatPosition, size: FloatSize): FloatPosition { - const clamped = clampFloatPosition(position, size); - if (typeof window !== "undefined") { - window.localStorage.setItem(NEW_TASK_MODAL_POSITION_STORAGE_KEY, JSON.stringify(clamped)); - } - return clamped; -} - -type FloatResizeDirection = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw"; -const NEW_TASK_RESIZE_DIRECTIONS: FloatResizeDirection[] = ["n", "s", "e", "w", "ne", "nw", "se", "sw"]; /* FNXC:GitHubImport 2026-07-16-15:20: Reference picker must surface all of a normal repo's open issues/PRs, not just the first 30. @@ -412,7 +327,6 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, const { t } = useTranslation("app"); const { confirm } = useConfirm(); const viewportMode = useViewportMode(); - const isTabletTouchResize = isTabletTouchViewport(viewportMode); useMobileScrollLock(isOpen); const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({ enabled: viewportMode === "mobile", @@ -427,186 +341,14 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, const [description, setDescription] = useState(""); const githubGeneratedDescriptionRef = useRef(""); const wasOpenRef = useRef(false); + const floatingFormRef = useRef(null); /* - FNXC:NewTask 2026-06-22-20:30: - Floating window position/size state (desktop only). Mobile keeps the full-screen sheet, so we only apply the floating panel style and drag/resize handlers when not mobile. A single active-drag teardown (drag OR resize) lives in dragTeardownRef; pointerup/pointercancel AND the unmount effect run it so an interrupted drag never leaks element pointer listeners or a pending rAF. + FNXC:ModalTouchGeometry 2026-07-27-18:00: + Phones and short viewports retain the keyboard-aware full-screen sheet. Desktop and tablet + presentations delegate drag, resize, clamping, persistence, and stacking to FloatingWindow. */ const isFloating = viewportMode !== "mobile"; - const [size, setSizeState] = useState(() => readFloatSize()); - const [position, setPositionState] = useState(() => readFloatPosition(readFloatSize())); - const dragTeardownRef = useRef<(() => void) | null>(null); - // FNXC:FloatingWindow 2026-06-22-21:30: Floating (desktop) New Task dialog shares the SINGLE cross-type floating z-index stack (floatingWindowStack). Mounting claims the front; tapping the panel (pointerdown/focus capture) raises it above every other floating modal regardless of type. Mobile keeps the full-screen sheet so this z-index is harmless there. - const [zIndex, setZIndex] = useState(() => nextFloatingZ()); - const bringToFront = useCallback(() => { - setZIndex((current) => (current >= currentFloatingZ() ? current : nextFloatingZ())); - }, []); - - const persistSize = useCallback((next: FloatSize) => { - setSizeState(writeFloatSize(next)); - }, []); - - const persistPosition = useCallback((next: FloatPosition, withSize: FloatSize) => { - setPositionState(writeFloatPosition(next, withSize)); - }, []); - - // FNXC:NewTask 2026-06-22-20:30: Header drag. setPointerCapture redirects the pointer stream to the captured header element, so element-scoped pointermove/up listeners receive the full drag even off the header; moves are rAF-batched; the panel is clamped on-screen. Close button clicks are excluded so dragging never swallows close. - const handleFloatingDragPointerDown = useCallback((event: ReactPointerEvent) => { - if ((event.target as HTMLElement).closest("button")) return; - event.preventDefault(); - const captureTarget = event.currentTarget; - const pointerId = event.pointerId; - captureTarget.setPointerCapture?.(pointerId); - const startX = event.clientX; - const startY = event.clientY; - const startPosition = position; - const currentSize = size; - const previousUserSelect = document.body.style.userSelect; - document.body.style.userSelect = "none"; - - let latest = startPosition; - let frame = 0; - - const handlePointerMove = (moveEvent: PointerEvent) => { - if (moveEvent.pointerId !== pointerId) return; - latest = { x: startPosition.x + moveEvent.clientX - startX, y: startPosition.y + moveEvent.clientY - startY }; - if (frame) return; - frame = requestAnimationFrame(() => { - frame = 0; - setPositionState(clampFloatPosition(latest, currentSize)); - }); - }; - const detachListeners = () => { - captureTarget.releasePointerCapture?.(pointerId); - captureTarget.removeEventListener("pointermove", handlePointerMove); - captureTarget.removeEventListener("pointerup", handlePointerUp); - captureTarget.removeEventListener("pointercancel", handlePointerUp); - }; - function handlePointerUp() { - if (frame) cancelAnimationFrame(frame); - persistPosition(latest, currentSize); - document.body.style.userSelect = previousUserSelect; - detachListeners(); - dragTeardownRef.current = null; - } - - dragTeardownRef.current = () => { - if (frame) cancelAnimationFrame(frame); - document.body.style.userSelect = previousUserSelect; - detachListeners(); - dragTeardownRef.current = null; - }; - - captureTarget.addEventListener("pointermove", handlePointerMove); - captureTarget.addEventListener("pointerup", handlePointerUp); - captureTarget.addEventListener("pointercancel", handlePointerUp); - }, [persistPosition, position, size]); - - // FNXC:NewTask 2026-06-22-20:30: Corner/edge resize, rAF-batched. West/north handles also shift the panel origin so the opposite edge stays pinned. Same teardown discipline as the drag. - const handleFloatingResizePointerDown = useCallback((event: ReactPointerEvent, direction: FloatResizeDirection) => { - event.preventDefault(); - event.stopPropagation(); - const captureTarget = event.currentTarget; - const pointerId = event.pointerId; - captureTarget.setPointerCapture?.(pointerId); - const startX = event.clientX; - const startY = event.clientY; - const startSize = size; - const startPosition = position; - const previousUserSelect = document.body.style.userSelect; - document.body.style.userSelect = "none"; - - let latestSize = startSize; - let latestPosition = startPosition; - let frame = 0; - - const handlePointerMove = (moveEvent: PointerEvent) => { - if (moveEvent.pointerId !== pointerId) return; - moveEvent.preventDefault(); - const dx = moveEvent.clientX - startX; - const dy = moveEvent.clientY - startY; - const nextSize = clampFloatSize({ - width: startSize.width + (direction.includes("e") ? dx : direction.includes("w") ? -dx : 0), - height: startSize.height + (direction.includes("s") ? dy : direction.includes("n") ? -dy : 0), - }); - const nextPosition = { - x: startPosition.x + (direction.includes("w") ? startSize.width - nextSize.width : 0), - y: startPosition.y + (direction.includes("n") ? startSize.height - nextSize.height : 0), - }; - latestSize = nextSize; - latestPosition = nextPosition; - if (frame) return; - frame = requestAnimationFrame(() => { - frame = 0; - setSizeState(latestSize); - setPositionState(clampFloatPosition(latestPosition, latestSize)); - }); - }; - const detachListeners = () => { - captureTarget.releasePointerCapture?.(pointerId); - captureTarget.removeEventListener("pointermove", handlePointerMove); - captureTarget.removeEventListener("pointerup", handlePointerUp); - captureTarget.removeEventListener("pointercancel", handlePointerUp); - }; - /* - FNXC:TaskModalResize 2026-07-26-10:51: - A tablet finger resize owns exactly the pointer that started it. Ignore another - finger's terminal event so it cannot release capture or persist partial geometry; - this preserves the established floating-window gesture isolation. - */ - function handlePointerUp(upEvent: PointerEvent) { - if (upEvent.pointerId !== pointerId) return; - upEvent.preventDefault(); - if (frame) cancelAnimationFrame(frame); - persistSize(latestSize); - persistPosition(latestPosition, latestSize); - document.body.style.userSelect = previousUserSelect; - detachListeners(); - dragTeardownRef.current = null; - } - - dragTeardownRef.current = () => { - if (frame) cancelAnimationFrame(frame); - document.body.style.userSelect = previousUserSelect; - detachListeners(); - dragTeardownRef.current = null; - }; - - captureTarget.addEventListener("pointermove", handlePointerMove); - captureTarget.addEventListener("pointerup", handlePointerUp); - captureTarget.addEventListener("pointercancel", handlePointerUp); - }, [persistPosition, persistSize, position, size]); - - /* - FNXC:TaskModalResize 2026-07-24-19:00: - Tablet resize handles must be keyboard-operable as well as touch-operable. Each focused edge - adjusts the dimensions it owns, clamps and persists exactly like a completed pointer resize, - and exposes the resulting geometry through its ARIA separator value. - */ - const handleFloatingResizeKeyDown = useCallback((event: ReactKeyboardEvent, direction: FloatResizeDirection) => { - let widthDelta = 0; - let heightDelta = 0; - const step = NEW_TASK_KEYBOARD_RESIZE_STEP; - - if (event.key === "ArrowRight") widthDelta = direction.includes("e") ? step : direction.includes("w") ? -step : 0; - if (event.key === "ArrowLeft") widthDelta = direction.includes("w") ? step : direction.includes("e") ? -step : 0; - if (event.key === "ArrowDown") heightDelta = direction.includes("s") ? step : direction.includes("n") ? -step : 0; - if (event.key === "ArrowUp") heightDelta = direction.includes("n") ? step : direction.includes("s") ? -step : 0; - if (widthDelta === 0 && heightDelta === 0) return; - - event.preventDefault(); - event.stopPropagation(); - const nextSize = clampFloatSize({ width: size.width + widthDelta, height: size.height + heightDelta }); - const nextPosition = clampFloatPosition({ - x: position.x + (direction.includes("w") ? size.width - nextSize.width : 0), - y: position.y + (direction.includes("n") ? size.height - nextSize.height : 0), - }, nextSize); - persistSize(nextSize); - persistPosition(nextPosition, nextSize); - }, [persistPosition, persistSize, position, size]); - - // FNXC:NewTask 2026-06-22-20:30: Run any active drag/resize teardown on unmount so element pointer listeners + a pending rAF never outlive the modal. - useEffect(() => () => dragTeardownRef.current?.(), []); const [dependencies, setDependencies] = useState([]); const [branchMode, setBranchMode] = useState("project-default"); @@ -1050,6 +792,58 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, } }, [handleClose]); + /* + FNXC:ModalTouchGeometry 2026-07-27-18:20: + FloatingWindow owns the desktop host, so New Task must route document Escape through its + existing discard-aware close path rather than bypassing the FN-8563 abandon-changes confirmation. + */ + useEffect(() => { + if (!isOpen || !isFloating) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.preventDefault(); + handleClose(); + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [handleClose, isFloating, isOpen]); + + /* + FNXC:ModalTouchGeometry 2026-07-26-18:01: + New Task keeps its keyboard focus boundary without opting into FloatingWindow's blocking + backdrop. Desktop clicks must pass through the transparent overlay, while Tab remains inside + the composer and every close route continues through the discard-aware handler. A blocking + duplicate-warning child temporarily owns Tab so its Cancel/Create controls remain reachable. + */ + useEffect(() => { + if (!isOpen || !isFloating) return; + const form = floatingFormRef.current; + if (!form) return; + const priorFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Tab") return; + // A duplicate warning is a blocking child dialog and owns keyboard traversal while open. + if (document.querySelector(".duplicate-warning-modal")) return; + const focusable = Array.from(form.querySelectorAll( + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', + )).filter((element) => !element.hasAttribute("hidden")); + if (focusable.length === 0) return; + const index = focusable.indexOf(document.activeElement as HTMLElement); + if (event.shiftKey && (index <= 0 || !form.contains(document.activeElement))) { + event.preventDefault(); + focusable.at(-1)?.focus(); + } else if (!event.shiftKey && (index === focusable.length - 1 || !form.contains(document.activeElement))) { + event.preventDefault(); + focusable[0]?.focus(); + } + }; + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("keydown", onKeyDown); + priorFocus?.focus(); + }; + }, [isFloating, isOpen]); + // Compute selected agent label for display const selectedAgent = selectedAgentId ? agents.find((agent) => agent.id === selectedAgentId) : undefined; const selectedAgentLabel = selectedAgent?.name ?? selectedAgentId; @@ -1197,152 +991,151 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, if (!isOpen) return null; - // FNXC:NewTask 2026-06-22-20:30: Desktop = floating fixed panel positioned by state-driven left/top/width/height. Mobile keeps the keyboard-aware full-screen sheet (no floating). The transparent click-through overlay never dismisses on click; the header X / Cancel / Escape are the only dismissals. - const panelStyle: CSSProperties = isFloating - ? { left: `${position.x}px`, top: `${position.y}px`, width: `${size.width}px`, height: `${size.height}px`, zIndex } - : keyboardStyle; + /* + FNXC:ModalTouchGeometry 2026-07-27-18:00: + Close routes remain handleClose so Escape, the header X, and Cancel all preserve the + abandon-changes confirmation. FloatingWindow intentionally keeps outside dismissal disabled. + */ + const duplicateWarning = duplicateMatches ? ( + + ) : null; - // FNXC:FloatingWindow 2026-06-22-22:30: Portaled to document.body so the floating New Task dialog shares the ONE root stacking context with the other floating modals; the shared cross-type z stack only orders correctly at the document root. Mobile sheet is position:fixed, unaffected. - return createPortal( - <> + const taskFormContents = ( +
-
- {isFloating && NEW_TASK_RESIZE_DIRECTIONS.map((direction) => ( -
handleFloatingResizePointerDown(event, direction)} - onKeyDown={(event) => handleFloatingResizeKeyDown(event, direction)} - /> - ))} -
-

{t("newTaskModal.title", "New Task")}

- +

{t("newTaskModal.title", "New Task")}

+
- {!setupReadinessLoading && visibleSetupHasWarnings && ( - - )} + {!setupReadinessLoading && visibleSetupHasWarnings && ( + + )} - +
{hasInvalidBranchSelection && ( -
{t("newTaskModal.branchRequired", "Branch name is required for this branch strategy.")}
+
{t("newTaskModal.branchRequired", "Branch name is required for this branch strategy.")}
)} -
- -
+
+ +
+
+ ); + + if (isFloating) { + return ( + <> + + {taskFormContents} + + {duplicateWarning} + + ); + } + + return createPortal( + <> +
+
+ {taskFormContents}
- {duplicateMatches && ( - - )} + {duplicateWarning} , document.body, ); diff --git a/packages/dashboard/app/components/RightDock.css b/packages/dashboard/app/components/RightDock.css index dfa0cfa1c7..e9073aecc8 100644 --- a/packages/dashboard/app/components/RightDock.css +++ b/packages/dashboard/app/components/RightDock.css @@ -179,24 +179,6 @@ The hosted view is a flex child of the dock body; without min-height:0 it cannot min-block-size: 0; } -/* -FNXC:RightDock 2026-06-22-17:40: -The right-dock pop-out is a FLOATING, DRAGGABLE, RESIZABLE, NON-BLOCKING window. The user positions it anywhere on screen and keeps using the app behind it. This overlay MUST out-specify the base `.modal-overlay` (which dims the page with a backdrop + blur). Both base and override are single-class selectors, so if styles.css loads after this file the dim/blur would win and the page would fade; qualify with `.modal-overlay` (two classes) so the pop-out reliably keeps a transparent, non-blurring, click-through backdrop regardless of stylesheet order. `pointer-events: none` lets behind-clicks pass through to the app; the floating panel re-enables `pointer-events: auto`. -*/ -.modal-overlay.right-dock-expand-modal-overlay { - align-items: stretch; - justify-content: flex-start; - padding: 0; - background: transparent; - backdrop-filter: none; - pointer-events: none; - /* - FNXC:FloatingWindow 2026-06-22-21:30: - Reset the base `.modal-overlay` z-index:100 to auto so this click-through overlay does NOT establish a stacking context. The floating panel carries an inline z-index from the SHARED floatingWindowStack (4000+); without this reset that inline z would be clamped inside the overlay's own stacking context and could never interleave with the other floating modal types (terminal, New Task, FloatingWindow) that all draw from the same stack. - */ - z-index: auto; -} - .right-dock-expand-modal { display: flex; flex-direction: column; @@ -204,22 +186,19 @@ The right-dock pop-out is a FLOATING, DRAGGABLE, RESIZABLE, NON-BLOCKING window. } /* -FNXC:RightDock 2026-06-22-17:40: -Floating panel positioned by state-driven inline `left/top/width/height`. min/max keep content usable and the panel on-screen. `resize: none` because resizing is handled by the corner/edge handles (the native grip conflicts with the drag/resize pointer handlers). `pointer-events: auto` re-enables interaction on the panel only. +FNXC:ModalTouchGeometry 2026-07-27-19:00: +The expanded dock is now content within FloatingWindow. Retain its visual identity while letting +its dimensions and position belong exclusively to the shared host rather than a nested fixed panel. */ .right-dock-expand-modal--floating { - --floating-window-shadow: var(--shadow-lg); - position: fixed; - min-width: calc(var(--space-2xl) * 7.5); - min-height: calc(var(--space-2xl) * 5.83); - max-width: calc(100vw - (var(--space-lg) * 2)); - max-height: calc(100dvh - (var(--space-lg) * 2)); + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + max-width: none; + max-height: none; resize: none; pointer-events: auto; - /* - FNXC:FloatingWindow 2026-06-23-23:32: - Floating modals share a gentle theme-controlled elevation token. Use the app shadow fallback instead of undefined --shadow-xl so themes can opt out or tune shadow strength consistently across Right Dock, New Task, Terminal, and shared FloatingWindow panels. - */ box-shadow: var(--floating-window-shadow, var(--shadow-lg)); } @@ -242,51 +221,6 @@ Touch dragging was janky because the browser claimed the header's touch stream f cursor: grabbing; } -/* -FNXC:RightDock 2026-06-22-17:40: -Edge + corner resize handles. touch-action:none keeps the drag from being hijacked by scroll/gestures so resizing stays smooth. -*/ -.right-dock-expand-resize-handle { - position: absolute; - z-index: 2; - touch-action: none; -} - -.right-dock-expand-resize-handle--n, -.right-dock-expand-resize-handle--s { - left: var(--space-sm); - right: var(--space-sm); - height: var(--space-sm); - cursor: ns-resize; -} - -.right-dock-expand-resize-handle--n { top: 0; } -.right-dock-expand-resize-handle--s { bottom: 0; } - -.right-dock-expand-resize-handle--e, -.right-dock-expand-resize-handle--w { - top: var(--space-sm); - bottom: var(--space-sm); - width: var(--space-sm); - cursor: ew-resize; -} - -.right-dock-expand-resize-handle--e { right: 0; } -.right-dock-expand-resize-handle--w { left: 0; } - -.right-dock-expand-resize-handle--ne, -.right-dock-expand-resize-handle--nw, -.right-dock-expand-resize-handle--se, -.right-dock-expand-resize-handle--sw { - width: var(--space-lg); - height: var(--space-lg); -} - -.right-dock-expand-resize-handle--ne { top: 0; right: 0; cursor: nesw-resize; } -.right-dock-expand-resize-handle--nw { top: 0; left: 0; cursor: nwse-resize; } -.right-dock-expand-resize-handle--se { bottom: 0; right: 0; cursor: nwse-resize; } -.right-dock-expand-resize-handle--sw { bottom: 0; left: 0; cursor: nesw-resize; } - .right-dock-expand-modal__header, .right-dock-expand-modal__title { display: flex; diff --git a/packages/dashboard/app/components/RightDockExpandModal.tsx b/packages/dashboard/app/components/RightDockExpandModal.tsx index 9425e5aacf..8f7c2355b3 100644 --- a/packages/dashboard/app/components/RightDockExpandModal.tsx +++ b/packages/dashboard/app/components/RightDockExpandModal.tsx @@ -1,102 +1,14 @@ -import { useCallback, useEffect, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type RefObject } from "react"; -import { createPortal } from "react-dom"; +import { useCallback, useEffect, type RefObject } from "react"; import { Maximize2, X } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { FloatingWindow } from "./FloatingWindow"; import { findOverflowViewEntry, type OverflowViewEntry, type OverflowViewKey, type OverflowViewRenderProps, type OverflowViewVisibilityOptions } from "./overflowViewRegistry"; -import { nextFloatingZ, currentFloatingZ } from "./floatingWindowStack"; import "./RightDock.css"; -const RIGHT_DOCK_EXPAND_MODAL_SIZE_STORAGE_KEY = "fusion:right-dock-expand-modal-size"; -const RIGHT_DOCK_EXPAND_MODAL_POSITION_STORAGE_KEY = "fusion:right-dock-expand-modal-position"; - -/* -FNXC:RightDock 2026-06-22-17:40: -The right-dock pop-out is a FLOATING, DRAGGABLE, RESIZABLE, NON-BLOCKING window. The user positions it anywhere on screen and keeps using the app behind it: NO background dimming/blur, and the overlay is `pointer-events: none` so behind-clicks pass through (only the panel re-enables `pointer-events: auto`). Because behind-clicks never reach the overlay there is no overlay click-to-dismiss; the explicit header close button is the only dismissal. This mirrors TerminalModal's floating mode (drag the header, resize from the corners, rAF-batched updates, a single dragTeardownRef invoked on pointerup/pointercancel AND on unmount so no document listeners leak). -*/ - const EXPAND_DEFAULT_WIDTH = 960; const EXPAND_DEFAULT_HEIGHT = 600; const EXPAND_MIN_WIDTH = 360; const EXPAND_MIN_HEIGHT = 280; -const EXPAND_VIEWPORT_PADDING = 16; - -interface ExpandSize { - width: number; - height: number; -} - -interface ExpandPosition { - x: number; - y: number; -} - -function clampExpandSize(size: ExpandSize): ExpandSize { - if (typeof window === "undefined") return size; - return { - width: Math.min(Math.max(size.width, EXPAND_MIN_WIDTH), Math.max(EXPAND_MIN_WIDTH, window.innerWidth - EXPAND_VIEWPORT_PADDING * 2)), - height: Math.min(Math.max(size.height, EXPAND_MIN_HEIGHT), Math.max(EXPAND_MIN_HEIGHT, window.innerHeight - EXPAND_VIEWPORT_PADDING * 2)), - }; -} - -function clampExpandPosition(position: ExpandPosition, size: ExpandSize): ExpandPosition { - if (typeof window === "undefined") return position; - return { - x: Math.min(Math.max(position.x, EXPAND_VIEWPORT_PADDING), Math.max(EXPAND_VIEWPORT_PADDING, window.innerWidth - size.width - EXPAND_VIEWPORT_PADDING)), - y: Math.min(Math.max(position.y, EXPAND_VIEWPORT_PADDING), Math.max(EXPAND_VIEWPORT_PADDING, window.innerHeight - size.height - EXPAND_VIEWPORT_PADDING)), - }; -} - -function readExpandSize(): ExpandSize { - if (typeof window === "undefined") return { width: EXPAND_DEFAULT_WIDTH, height: EXPAND_DEFAULT_HEIGHT }; - try { - const raw = window.localStorage.getItem(RIGHT_DOCK_EXPAND_MODAL_SIZE_STORAGE_KEY); - if (raw) { - const parsed = JSON.parse(raw) as Partial; - if (typeof parsed.width === "number" && typeof parsed.height === "number") { - return clampExpandSize({ width: parsed.width, height: parsed.height }); - } - } - } catch { - // ignore corrupted persisted size - } - return clampExpandSize({ width: EXPAND_DEFAULT_WIDTH, height: EXPAND_DEFAULT_HEIGHT }); -} - -function writeExpandSize(size: ExpandSize): ExpandSize { - const clamped = clampExpandSize(size); - if (typeof window !== "undefined") { - window.localStorage.setItem(RIGHT_DOCK_EXPAND_MODAL_SIZE_STORAGE_KEY, JSON.stringify(clamped)); - } - return clamped; -} - -function readExpandPosition(size: ExpandSize): ExpandPosition { - if (typeof window === "undefined") return { x: EXPAND_VIEWPORT_PADDING, y: EXPAND_VIEWPORT_PADDING }; - try { - const raw = window.localStorage.getItem(RIGHT_DOCK_EXPAND_MODAL_POSITION_STORAGE_KEY); - if (raw) { - const parsed = JSON.parse(raw) as Partial; - if (typeof parsed.x === "number" && typeof parsed.y === "number") { - return clampExpandPosition({ x: parsed.x, y: parsed.y }, size); - } - } - } catch { - // ignore corrupted persisted position - } - // Default: roughly centered. - return clampExpandPosition({ x: (window.innerWidth - size.width) / 2, y: (window.innerHeight - size.height) / 2 }, size); -} - -function writeExpandPosition(position: ExpandPosition, size: ExpandSize): ExpandPosition { - const clamped = clampExpandPosition(position, size); - if (typeof window !== "undefined") { - window.localStorage.setItem(RIGHT_DOCK_EXPAND_MODAL_POSITION_STORAGE_KEY, JSON.stringify(clamped)); - } - return clamped; -} - -type ExpandResizeDirection = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw"; -const EXPAND_RESIZE_DIRECTIONS: ExpandResizeDirection[] = ["n", "s", "e", "w", "ne", "nw", "se", "sw"]; type RenderableOverflowViewEntry = OverflowViewEntry & Required>; @@ -129,165 +41,11 @@ export function RightDockExpandModal({ const resolvedEntry = viewKey ? findOverflowViewEntry(viewKey, visibilityOptions) : undefined; const entry: RenderableOverflowViewEntry | undefined = resolvedEntry?.render ? { ...resolvedEntry, render: resolvedEntry.render } : undefined; - const [size, setSizeState] = useState(() => readExpandSize()); - const [position, setPositionState] = useState(() => readExpandPosition(readExpandSize())); - // FNXC:FloatingWindow 2026-06-22-21:30: The right-dock pop-out shares the SINGLE cross-type floating z-index stack (floatingWindowStack). Mounting claims the front; tapping the panel (pointerdown/focus capture) raises it above every other floating modal regardless of type. - const [zIndex, setZIndex] = useState(() => nextFloatingZ()); - const bringToFront = useCallback(() => { - setZIndex((current) => (current >= currentFloatingZ() ? current : nextFloatingZ())); - }, []); - - /* - FNXC:RightDock 2026-06-22-17:40: - A single active-drag teardown lives here (drag OR resize). pointerup/pointercancel run it, and the unmount effect runs it too, so a drag interrupted by close/unmount never leaks document pointer listeners or a pending rAF — this was a P1 in review of the terminal floating window. - */ - const dragTeardownRef = useRef<(() => void) | null>(null); - - const persistSize = useCallback((next: ExpandSize) => { - setSizeState(writeExpandSize(next)); - }, []); - - const persistPosition = useCallback((next: ExpandPosition, withSize: ExpandSize) => { - setPositionState(writeExpandPosition(next, withSize)); - }, []); - const closeAndRestoreFocus = useCallback(() => { onClose(); window.setTimeout(() => returnFocusRef?.current?.focus(), 0); }, [onClose, returnFocusRef]); - /* - FNXC:RightDock 2026-06-22-17:40: - Header drag: pointerdown on the title bar moves the panel via state-driven `position: fixed; left/top`. Pointer capture keeps the drag alive past the header bounds, updates are rAF-batched so the move stays smooth, and the panel is clamped on-screen. Clicks on the close button are excluded so dragging never swallows the close. - - FNXC:RightDock 2026-06-22-18:50: - Touch smoothness fix: listen for pointermove/up on the CAPTURED element (`captureTarget` = event.currentTarget) rather than `document`. `setPointerCapture` redirects every move for this pointerId to that element, so element-scoped listeners receive the full stream even when the finger drifts off the header — and they pair cleanly with `touch-action: none` (CSS) without a separate non-passive document listener. clientX/clientY are read from the captured pointer's move events. Raw moves are coalesced into a single rAF (`frame`) so we set left/top at most once per frame and never thrash layout on a flood of touch-move events. - */ - const handleFloatingDragPointerDown = useCallback((event: ReactPointerEvent) => { - if ((event.target as HTMLElement).closest("button")) return; - event.preventDefault(); - const captureTarget = event.currentTarget; - const pointerId = event.pointerId; - captureTarget.setPointerCapture?.(pointerId); - const startX = event.clientX; - const startY = event.clientY; - const startPosition = position; - const currentSize = size; - const previousUserSelect = document.body.style.userSelect; - document.body.style.userSelect = "none"; - - let latest = startPosition; - let frame = 0; - - const handlePointerMove = (moveEvent: PointerEvent) => { - if (moveEvent.pointerId !== pointerId) return; - latest = { x: startPosition.x + moveEvent.clientX - startX, y: startPosition.y + moveEvent.clientY - startY }; - if (frame) return; - frame = requestAnimationFrame(() => { - frame = 0; - setPositionState(clampExpandPosition(latest, currentSize)); - }); - }; - const detachListeners = () => { - captureTarget.releasePointerCapture?.(pointerId); - captureTarget.removeEventListener("pointermove", handlePointerMove); - captureTarget.removeEventListener("pointerup", handlePointerUp); - captureTarget.removeEventListener("pointercancel", handlePointerUp); - }; - function handlePointerUp() { - if (frame) cancelAnimationFrame(frame); - persistPosition(latest, currentSize); - document.body.style.userSelect = previousUserSelect; - detachListeners(); - dragTeardownRef.current = null; - } - - // FNXC:RightDock 2026-06-22-17:40: Close/unmount-mid-drag teardown cancels the rAF and drops the listeners without persisting a partial move. - dragTeardownRef.current = () => { - if (frame) cancelAnimationFrame(frame); - document.body.style.userSelect = previousUserSelect; - detachListeners(); - dragTeardownRef.current = null; - }; - - captureTarget.addEventListener("pointermove", handlePointerMove); - captureTarget.addEventListener("pointerup", handlePointerUp); - captureTarget.addEventListener("pointercancel", handlePointerUp); - }, [persistPosition, position, size]); - - /* - FNXC:RightDock 2026-06-22-17:40: - Corner/edge resize: pointer events resize the panel, rAF-batched for smoothness. West/north handles also shift the panel origin so the opposite edge stays pinned. Same teardown discipline as the drag. - */ - const handleFloatingResizePointerDown = useCallback((event: ReactPointerEvent, direction: ExpandResizeDirection) => { - event.preventDefault(); - event.stopPropagation(); - const captureTarget = event.currentTarget; - const pointerId = event.pointerId; - captureTarget.setPointerCapture?.(pointerId); - const startX = event.clientX; - const startY = event.clientY; - const startSize = size; - const startPosition = position; - const previousUserSelect = document.body.style.userSelect; - document.body.style.userSelect = "none"; - - let latestSize = startSize; - let latestPosition = startPosition; - let frame = 0; - - const handlePointerMove = (moveEvent: PointerEvent) => { - if (moveEvent.pointerId !== pointerId) return; - const dx = moveEvent.clientX - startX; - const dy = moveEvent.clientY - startY; - const nextSize = clampExpandSize({ - width: startSize.width + (direction.includes("e") ? dx : direction.includes("w") ? -dx : 0), - height: startSize.height + (direction.includes("s") ? dy : direction.includes("n") ? -dy : 0), - }); - const nextPosition = { - x: startPosition.x + (direction.includes("w") ? startSize.width - nextSize.width : 0), - y: startPosition.y + (direction.includes("n") ? startSize.height - nextSize.height : 0), - }; - latestSize = nextSize; - latestPosition = nextPosition; - if (frame) return; - frame = requestAnimationFrame(() => { - frame = 0; - setSizeState(latestSize); - setPositionState(clampExpandPosition(latestPosition, latestSize)); - }); - }; - const detachListeners = () => { - captureTarget.releasePointerCapture?.(pointerId); - captureTarget.removeEventListener("pointermove", handlePointerMove); - captureTarget.removeEventListener("pointerup", handlePointerUp); - captureTarget.removeEventListener("pointercancel", handlePointerUp); - }; - function handlePointerUp() { - if (frame) cancelAnimationFrame(frame); - persistSize(latestSize); - persistPosition(latestPosition, latestSize); - document.body.style.userSelect = previousUserSelect; - detachListeners(); - dragTeardownRef.current = null; - } - - // FNXC:RightDock 2026-06-22-17:40: Close/unmount-mid-resize teardown. - dragTeardownRef.current = () => { - if (frame) cancelAnimationFrame(frame); - document.body.style.userSelect = previousUserSelect; - detachListeners(); - dragTeardownRef.current = null; - }; - - captureTarget.addEventListener("pointermove", handlePointerMove); - captureTarget.addEventListener("pointerup", handlePointerUp); - captureTarget.addEventListener("pointercancel", handlePointerUp); - }, [persistPosition, persistSize, position, size]); - - // FNXC:RightDock 2026-06-22-17:40: Run any active drag/resize teardown on unmount so document pointer listeners + a pending rAF never outlive the modal. - useEffect(() => () => dragTeardownRef.current?.(), []); - useEffect(() => { if (entry) return undefined; return () => { @@ -295,63 +53,50 @@ export function RightDockExpandModal({ }; }, [entry, returnFocusRef]); - if (!entry) { - return null; - } + if (!entry) return null; const Icon = entry.icon; const expandedViewLabel = t("rightDock.viewExpanded", "{{label}} expanded", { label: entry.label }); - const panelStyle = { - left: `${position.x}px`, - top: `${position.y}px`, - width: `${size.width}px`, - height: `${size.height}px`, - zIndex, - } as CSSProperties; - - // FNXC:FloatingWindow 2026-06-22-22:30: Portaled to document.body so this floating modal shares the ONE root stacking context with the other floating modals (FloatingWindow/terminal/New Task) — the shared 10100+ z stack only orders correctly across types when they all live at the document root. - return createPortal( -
+ /* + FNXC:ModalTouchGeometry 2026-07-27-17:00: + FN-8620 replaces this pop-out's duplicate captured-pointer geometry engine with FloatingWindow. + Its custom header stays the delegated drag surface, while the shared host owns clamping, stack + claims, tablet touch targets, and teardown. The old size/position key pair intentionally resets + once to this single geometry record. + */ + return ( +
- {EXPAND_RESIZE_DIRECTIONS.map((direction) => ( -
handleFloatingResizePointerDown(event, direction)} - /> - ))} -
-
- - - {entry.label} -
- -
- {/* - FNXC:RightDockFiles 2026-06-22-15:00: - Tag the render props with `surface="expand"` so registry entries (notably Files) deterministically choose their pop-out layout instead of guessing from a measured container width. DockFilesView reads this to force its LEFT|RIGHT two-pane layout. - */} -
- {entry.render({ ...renderProps, surface: "expand" })} +
+ + + {entry.label}
+
-
, - document.body, +
+ {entry.render({ ...renderProps, surface: "expand" })} +
+ ); } diff --git a/packages/dashboard/app/components/SetupWizardModal.tsx b/packages/dashboard/app/components/SetupWizardModal.tsx index f91c7be754..84681af184 100644 --- a/packages/dashboard/app/components/SetupWizardModal.tsx +++ b/packages/dashboard/app/components/SetupWizardModal.tsx @@ -8,6 +8,7 @@ import { useConfirm } from "../hooks/useConfirm"; import { openExternalUrl } from "../utils/open-external"; import { DirectoryPicker } from "./DirectoryPicker"; import { suggestProjectName } from "../utils/projectDetection"; +import { FloatingWindow } from "./FloatingWindow"; /* FNXC:TaskPrefix 2026-06-24-19:00: @@ -449,7 +450,22 @@ export function SetupWizardModal({ const modalClassName = `modal setup-wizard-modal${state.step === "agent" ? " setup-wizard-modal--agent" : ""}`; return ( -
+ {}} + hideHeader + dragHandleSelector=".setup-wizard-modal .setup-wizard-header" + className="floating-window--setup-wizard" + defaultSize={{ width: 760, height: 680 }} + minSize={{ width: 480, height: 360 }} + persistGeometryKey="floating-window:setup-wizard" + suspendGeometryPersistenceOnMobile + suspendGeometryPersistenceOnShortViewport + modal + > + {/* FNXC:ModalTouchGeometry 2026-07-26-16:22: The first-run wizard remains blocking, while its reflowable steps use shared tablet geometry and suspend it for sheet viewports. */}
{/* Header */}
@@ -1000,6 +1016,6 @@ export function SetupWizardModal({ )} -
+ ); } diff --git a/packages/dashboard/app/components/SubtaskBreakdownModal.tsx b/packages/dashboard/app/components/SubtaskBreakdownModal.tsx index 4cd05c2150..7ad539a04e 100644 --- a/packages/dashboard/app/components/SubtaskBreakdownModal.tsx +++ b/packages/dashboard/app/components/SubtaskBreakdownModal.tsx @@ -25,6 +25,7 @@ import { useConfirm } from "../hooks/useConfirm"; import { useMobileKeyboard } from "../hooks/useMobileKeyboard"; import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; import { useViewportMode } from "../hooks/useViewportMode"; +import { FloatingWindow } from "./FloatingWindow"; const WARNING_ICON = "⚠️"; @@ -604,7 +605,8 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT if (!isOpen) return null; return ( -
event.target === event.currentTarget && void handleClose()} role="dialog" aria-modal="true"> + void handleClose()} hideHeader dragHandleSelector=".planning-modal .modal-header" className="floating-window--subtask-breakdown" defaultSize={{ width: 760, height: 640 }} minSize={{ width: 440, height: 320 }} persistGeometryKey="floating-window:subtask-breakdown" suspendGeometryPersistenceOnMobile suspendGeometryPersistenceOnShortViewport closeOnOutsidePointerDown modal> + {/* FNXC:ModalTouchGeometry 2026-07-26-16:22: Subtask close stays on handleClose for its confirmation path while FloatingWindow owns all tablet geometry. */}
@@ -923,7 +925,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
-
+
); } diff --git a/packages/dashboard/app/components/TerminalModal.css b/packages/dashboard/app/components/TerminalModal.css index a9f2a3362a..41599d2aba 100644 --- a/packages/dashboard/app/components/TerminalModal.css +++ b/packages/dashboard/app/components/TerminalModal.css @@ -52,8 +52,7 @@ FN-7621 recurrence #3 root cause: styles.css's mobile `@media (max-width: 768px) touch-action: pan-x pan-y; } -.modal-overlay.terminal-modal-overlay--docked.terminal-modal-overlay--docked, -.modal-overlay.terminal-modal-overlay--floating.terminal-modal-overlay--floating { +.modal-overlay.terminal-modal-overlay--docked.terminal-modal-overlay--docked { align-items: stretch; justify-content: flex-end; padding: 0; @@ -63,14 +62,6 @@ FN-7621 recurrence #3 root cause: styles.css's mobile `@media (max-width: 768px) pointer-events: none; } -/* -FNXC:FloatingWindow 2026-06-22-21:30: -Only the FLOATING terminal joins the shared cross-type floating stack. Reset the base `.modal-overlay` z-index:100 to auto so this click-through overlay does NOT establish a stacking context; the floating panel's inline z-index (from floatingWindowStack, 4000+) then interleaves at the root with the right-dock pop-out, the floating New Task dialog, and FloatingWindow. Docked mode keeps the base overlay stacking (full-width bottom panel) and is intentionally excluded. -*/ -.modal-overlay.terminal-modal-overlay--floating { - z-index: auto; -} - .modal.terminal-modal { /* Initial dimensions are applied only when no persisted size has been restored — see :not([style*=...]) selectors below. */ @@ -92,6 +83,22 @@ Only the FLOATING terminal joins the shared cross-type floating stack. Reset the height: min(85vh, calc(100dvh - 40px)); } +/* +FNXC:ModalTouchGeometry 2026-07-27-19:00: +The floating terminal is now content inside FloatingWindow, so its old standalone dimensions and +native resize grip must not escape the shared geometry host. The docked and below presentations +retain their own sizing rules below. +*/ +.floating-window .modal.terminal-modal.terminal-modal--floating { + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + max-width: none; + max-height: none; + resize: none; +} + .modal.terminal-modal.terminal-modal--docked { --floating-window-shadow: var(--shadow-lg); position: fixed; @@ -200,88 +207,6 @@ FN-7897: reserve space for the fixed ExecutorStatusBar footer when it is rendere background: var(--border); } -.modal.terminal-modal.terminal-modal--floating { - --floating-window-shadow: var(--shadow-lg); - position: fixed; - left: var(--terminal-float-x); - top: var(--terminal-float-y); - width: var(--terminal-float-width); - height: var(--terminal-float-height); - min-width: calc(var(--space-xl) * 20); - min-height: calc(var(--space-xl) * 13.333); - max-width: calc(100vw - (var(--space-lg) * 2)); - max-height: calc(100dvh - (var(--space-lg) * 2)); - resize: none; - pointer-events: auto; - /* - FNXC:FloatingWindow 2026-06-23-23:32: - Floating modals share a gentle theme-controlled elevation token. Use the app shadow fallback instead of undefined --shadow-xl so themes can opt out or tune shadow strength consistently across Terminal, New Task, Right Dock, and shared FloatingWindow panels. - */ - box-shadow: var(--floating-window-shadow, var(--shadow-lg)); -} - -/* -FNXC:Terminal 2026-06-22-19:50: -The floating-mode header is the move grip. `touch-action: none` is required so a touch-drag on it is delivered as a continuous pointermove stream (paired with setPointerCapture on the captured element) instead of being hijacked by the browser into page scroll/pan. Without it the floating drag stutters on touch — same fix the right-dock pop-out drag handle uses. cursor: grab/grabbing signals the move affordance on desktop. -*/ -.terminal-header--draggable { - cursor: grab; - user-select: none; - touch-action: none; - min-height: 48px; -} - -.terminal-header--draggable:active { - cursor: grabbing; -} - -.terminal-floating-resize-handle { - position: absolute; - z-index: 2; - touch-action: none; -} - -/* -FNXC:TerminalModalControls 2026-07-24-12:35: -Tablet operators resize the global floating terminal with the same edge and corner targets as other -floating windows. Use tokenized, larger hit regions so touch gestures are discoverable without -adding a second header or exposing controls in the fixed phone and embedded presentations. -*/ -.terminal-floating-resize-handle--n, -.terminal-floating-resize-handle--s { - left: var(--space-md); - right: var(--space-md); - height: var(--space-md); - cursor: ns-resize; -} - -.terminal-floating-resize-handle--n { top: 0; } -.terminal-floating-resize-handle--s { bottom: 0; } - -.terminal-floating-resize-handle--e, -.terminal-floating-resize-handle--w { - top: var(--space-md); - bottom: var(--space-md); - width: var(--space-md); - cursor: ew-resize; -} - -.terminal-floating-resize-handle--e { right: 0; } -.terminal-floating-resize-handle--w { left: 0; } - -.terminal-floating-resize-handle--ne, -.terminal-floating-resize-handle--nw, -.terminal-floating-resize-handle--se, -.terminal-floating-resize-handle--sw { - width: var(--space-xl); - height: var(--space-xl); -} - -.terminal-floating-resize-handle--ne { top: 0; right: 0; cursor: nesw-resize; } -.terminal-floating-resize-handle--nw { top: 0; left: 0; cursor: nwse-resize; } -.terminal-floating-resize-handle--se { bottom: 0; right: 0; cursor: nwse-resize; } -.terminal-floating-resize-handle--sw { bottom: 0; left: 0; cursor: nesw-resize; } - .terminal-header { display: flex; align-items: center; @@ -1900,17 +1825,6 @@ The Android keyboard-open recurrence can start with a touch-primary visualViewpo This keeps the shared header move grip and all eight resize targets reachable at exactly 768px without exposing any geometry controls on true-phone sheets. */ - .modal.terminal-modal.terminal-modal--tablet.terminal-modal--floating { - min-width: calc(var(--space-xl) * 20) !important; - min-height: calc(var(--space-xl) * 13.333) !important; - width: var(--terminal-float-width) !important; - height: var(--terminal-float-height) !important; - max-width: calc(100vw - (var(--space-lg) * 2)) !important; - max-height: calc(100dvh - (var(--space-lg) * 2)) !important; - border-radius: var(--radius-lg); - border: 1px solid var(--border); - } - .modal.terminal-modal.terminal-modal--tablet.terminal-modal--docked { min-width: 0 !important; min-height: calc(var(--space-xl) * 10) !important; diff --git a/packages/dashboard/app/components/TerminalModal.tsx b/packages/dashboard/app/components/TerminalModal.tsx index d5bc437003..3e88e830b6 100644 --- a/packages/dashboard/app/components/TerminalModal.tsx +++ b/packages/dashboard/app/components/TerminalModal.tsx @@ -35,7 +35,8 @@ import { useTerminal } from "../hooks/useTerminal"; import { useTerminalSessions } from "../hooks/useTerminalSessions"; import { useWorkspaces } from "../hooks/useWorkspaces"; import { getViewportMode, isMobileViewport } from "../hooks/useViewportMode"; -import { nextFloatingZ, currentFloatingZ } from "./floatingWindowStack"; +import { FloatingWindow, FLOATING_WINDOW_GEOMETRY_CHANGE_EVENT } from "./FloatingWindow"; +import { currentFloatingZ } from "./floatingWindowStack"; import { getPathBasename } from "../utils/pathDisplay"; import { DEFAULT_TERMINAL_PREFERENCES, @@ -97,20 +98,6 @@ const TERMINAL_FLOAT_DEFAULT_WIDTH = 960; const TERMINAL_FLOAT_DEFAULT_HEIGHT = 560; const TERMINAL_FLOAT_MIN_WIDTH = 480; const TERMINAL_FLOAT_MIN_HEIGHT = 320; -const TERMINAL_FLOAT_VIEWPORT_PADDING = 16; - -type TerminalResizeDirection = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw"; -const TERMINAL_RESIZE_DIRECTIONS: TerminalResizeDirection[] = ["n", "s", "e", "w", "ne", "nw", "se", "sw"]; - -interface TerminalFloatSize { - width: number; - height: number; -} - -interface TerminalFloatPosition { - x: number; - y: number; -} interface TerminalWorkspaceMenuPosition { top: number; @@ -168,66 +155,6 @@ function writeTerminalDockedHeight(height: number, projectId?: string, mode: "do return clamped; } -function clampTerminalFloatSize(size: TerminalFloatSize): TerminalFloatSize { - if (typeof window === "undefined") return size; - return { - width: Math.min(Math.max(size.width, TERMINAL_FLOAT_MIN_WIDTH), Math.max(TERMINAL_FLOAT_MIN_WIDTH, window.innerWidth - TERMINAL_FLOAT_VIEWPORT_PADDING * 2)), - height: Math.min(Math.max(size.height, TERMINAL_FLOAT_MIN_HEIGHT), Math.max(TERMINAL_FLOAT_MIN_HEIGHT, window.innerHeight - TERMINAL_FLOAT_VIEWPORT_PADDING * 2)), - }; -} - -function clampTerminalFloatPosition(position: TerminalFloatPosition, size: TerminalFloatSize): TerminalFloatPosition { - if (typeof window === "undefined") return position; - return { - x: Math.min(Math.max(position.x, TERMINAL_FLOAT_VIEWPORT_PADDING), Math.max(TERMINAL_FLOAT_VIEWPORT_PADDING, window.innerWidth - size.width - TERMINAL_FLOAT_VIEWPORT_PADDING)), - y: Math.min(Math.max(position.y, TERMINAL_FLOAT_VIEWPORT_PADDING), Math.max(TERMINAL_FLOAT_VIEWPORT_PADDING, window.innerHeight - size.height - TERMINAL_FLOAT_VIEWPORT_PADDING)), - }; -} - -function readTerminalFloatSize(projectId?: string): TerminalFloatSize { - if (typeof window === "undefined") return { width: TERMINAL_FLOAT_DEFAULT_WIDTH, height: TERMINAL_FLOAT_DEFAULT_HEIGHT }; - try { - const raw = window.localStorage.getItem(`fusion:terminal-modal-size-${projectId ?? "default"}`) ?? window.localStorage.getItem("fusion:terminal-modal-size"); - if (raw) { - const parsed = JSON.parse(raw) as Partial; - if (typeof parsed.width === "number" && typeof parsed.height === "number") return clampTerminalFloatSize({ width: parsed.width, height: parsed.height }); - } - } catch { - // ignore corrupted size - } - return clampTerminalFloatSize({ width: TERMINAL_FLOAT_DEFAULT_WIDTH, height: TERMINAL_FLOAT_DEFAULT_HEIGHT }); -} - -function writeTerminalFloatSize(size: TerminalFloatSize, projectId?: string): TerminalFloatSize { - const clamped = clampTerminalFloatSize(size); - if (typeof window !== "undefined") { - window.localStorage.setItem(`fusion:terminal-modal-size-${projectId ?? "default"}`, JSON.stringify(clamped)); - } - return clamped; -} - -function readTerminalFloatPosition(size: TerminalFloatSize, projectId?: string): TerminalFloatPosition { - if (typeof window === "undefined") return { x: TERMINAL_FLOAT_VIEWPORT_PADDING, y: TERMINAL_FLOAT_VIEWPORT_PADDING }; - try { - const raw = window.localStorage.getItem(`fusion:terminal-float-pos-${projectId ?? "default"}`); - if (raw) { - const parsed = JSON.parse(raw) as Partial; - if (typeof parsed.x === "number" && typeof parsed.y === "number") return clampTerminalFloatPosition({ x: parsed.x, y: parsed.y }, size); - } - } catch { - // ignore corrupted position - } - return clampTerminalFloatPosition({ x: window.innerWidth - size.width - TERMINAL_FLOAT_VIEWPORT_PADDING, y: TERMINAL_FLOAT_VIEWPORT_PADDING }, size); -} - -function writeTerminalFloatPosition(position: TerminalFloatPosition, size: TerminalFloatSize, projectId?: string): TerminalFloatPosition { - const clamped = clampTerminalFloatPosition(position, size); - if (typeof window !== "undefined") { - window.localStorage.setItem(`fusion:terminal-float-pos-${projectId ?? "default"}`, JSON.stringify(clamped)); - } - return clamped; -} - const TERMINAL_KEY_LABELS = { ctrl: "Ctrl", alt: "Alt", @@ -589,8 +516,6 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG const [pendingInitialCommandGeneration, setPendingInitialCommandGeneration] = useState(0); const [displayMode, setDisplayModeState] = useState(() => readTerminalDisplayMode(projectId)); const [dockedHeight, setDockedHeight] = useState(() => readTerminalDockedHeight(projectId)); - const [floatingSize, setFloatingSize] = useState(() => readTerminalFloatSize(projectId)); - const [floatingPosition, setFloatingPosition] = useState(() => readTerminalFloatPosition(readTerminalFloatSize(projectId), projectId)); const [isMobileTerminal, setIsMobileTerminal] = useState(() => isTerminalMobileViewport()); const [isTabletTerminal, setIsTabletTerminal] = useState(() => getViewportMode() === "tablet"); const [tabsOverflow, setTabsOverflow] = useState(false); @@ -601,12 +526,6 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG const isDockedMode = !embedded && !isMobileTerminal && displayMode === "docked"; const isFloatingMode = !embedded && !isMobileTerminal && displayMode === "floating"; const isBelowMode = !embedded && !isMobileTerminal && displayMode === "below"; - // FNXC:FloatingWindow 2026-06-22-21:30: The FLOATING terminal shares the SINGLE cross-type floating z-index stack (floatingWindowStack) so tapping it raises it above every other floating modal regardless of type. A fresh z is claimed each time the modal opens (see effect below); tapping the panel (pointerdown/focus capture) re-raises it. Docked/mobile modes ignore this z-index (full-width bottom panel / full-screen sheet). - const [floatingZ, setFloatingZ] = useState(() => nextFloatingZ()); - const bringFloatingToFront = useCallback(() => { - if (!isFloatingMode) return; - setFloatingZ((current) => (current >= currentFloatingZ() ? current : nextFloatingZ())); - }, [isFloatingMode]); const terminalRef = useRef(null); const modalRef = useRef(null); @@ -700,9 +619,6 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG useEffect(() => { setDisplayModeState(readTerminalDisplayMode(projectId)); setDockedHeight(readTerminalDockedHeight(projectId)); - const nextSize = readTerminalFloatSize(projectId); - setFloatingSize(nextSize); - setFloatingPosition(readTerminalFloatPosition(nextSize, projectId)); }, [projectId]); useEffect(() => { @@ -736,25 +652,6 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG window.dispatchEvent(new CustomEvent("fusion:terminal-display-mode-change", { detail: { projectId, mode } })); }, [projectId]); - const persistFloatingSize = useCallback((size: TerminalFloatSize) => { - setFloatingSize(writeTerminalFloatSize(size, projectId)); - }, [projectId]); - - const persistFloatingPosition = useCallback((position: TerminalFloatPosition, size = floatingSize) => { - setFloatingPosition(writeTerminalFloatPosition(position, size, projectId)); - }, [floatingSize, projectId]); - - /* - FNXC:Terminal 2026-06-21-22:26: - FN-6887 requires desktop/tablet terminal opens to default to a project-scoped docked bottom panel. Persist `fusion:terminal-display-mode-${projectId}` and `fusion:terminal-docked-height-${projectId}` so each project restores its preferred panel mode and height without affecting mobile fullscreen behavior. - - FNXC:Terminal 2026-06-21-22:45: - The pop-out terminal mode uses project-scoped `fusion:terminal-modal-size-${projectId}` and `fusion:terminal-float-pos-${projectId}` keys so floating windows restore independently per project while avoiding the old bottom-right native resize grip conflict. - */ - /* - FNXC:Terminal 2026-06-22-19:50: - Docked top-edge resize, smooth on touch + desktop (same technique as the right-dock pop-out RightDockExpandModal). On pointerdown we setPointerCapture on the handle and attach pointermove/up/cancel to the CAPTURED element (`captureTarget` = event.currentTarget), NOT `document` — capture redirects the full pointer stream for this pointerId to that element so element-scoped listeners receive every move even when the finger drifts off the handle, and they pair cleanly with the handle's `touch-action: none` (CSS) without a non-passive document listener. Moves are filtered by pointerId and coalesced into one rAF, so we set height at most once per frame and never thrash layout on a flood of touch-move events. localStorage is written only on pointerup (existing behavior). Teardown (pointerup/cancel + unmount via dragTeardownRef) cancels the pending rAF, releases pointer capture, and detaches listeners. - */ const handleDockedResizePointerDown = useCallback((event: ReactPointerEvent) => { if (!isDockedMode && !isBelowMode) return; event.preventDefault(); @@ -806,133 +703,6 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG captureTarget.addEventListener("pointercancel", handlePointerUp); }, [dockedHeight, isBelowMode, isDockedMode, projectId]); - /* - FNXC:Terminal 2026-06-22-19:50: - Floating-window move (drag the header grip), smooth on touch + desktop. Pointer capture + captured-element (`captureTarget`) listeners filtered by pointerId, identical to the right-dock pop-out drag. Raw pointer coords are stored in `latest` and applied via one rAF per frame, so a flood of touch-move events coalesces into a single state set and never thrashes layout. State-only updates during the drag; localStorage is persisted once on pointerup (the old per-move persistFloatingPosition wrote localStorage on every move, which janked touch drags). Teardown cancels the rAF, releases capture, and detaches listeners on pointerup/cancel and on unmount. - */ - const handleFloatingDragPointerDown = useCallback((event: ReactPointerEvent) => { - if (!isFloatingMode || (event.target as HTMLElement).closest("button")) return; - event.preventDefault(); - const captureTarget = event.currentTarget; - const pointerId = event.pointerId; - captureTarget.setPointerCapture?.(pointerId); - const startX = event.clientX; - const startY = event.clientY; - const startPosition = floatingPosition; - const currentSize = floatingSize; - const previousUserSelect = document.body.style.userSelect; - document.body.style.userSelect = "none"; - - let latest = startPosition; - let frame = 0; - - const handlePointerMove = (moveEvent: PointerEvent) => { - if (moveEvent.pointerId !== pointerId) return; - latest = { x: startPosition.x + moveEvent.clientX - startX, y: startPosition.y + moveEvent.clientY - startY }; - if (frame) return; - frame = requestAnimationFrame(() => { - frame = 0; - setFloatingPosition(clampTerminalFloatPosition(latest, currentSize)); - }); - }; - const detachListeners = () => { - captureTarget.releasePointerCapture?.(pointerId); - captureTarget.removeEventListener("pointermove", handlePointerMove); - captureTarget.removeEventListener("pointerup", handlePointerUp); - captureTarget.removeEventListener("pointercancel", handlePointerUp); - }; - function handlePointerUp() { - if (frame) cancelAnimationFrame(frame); - persistFloatingPosition(latest, currentSize); - document.body.style.userSelect = previousUserSelect; - detachListeners(); - dragTeardownRef.current = null; - } - - // FNXC:Terminal 2026-06-22-19:50: Unmount/close-mid-drag teardown cancels the rAF, releases capture, and detaches the captured-element listeners without persisting a partial move. - dragTeardownRef.current = () => { - if (frame) cancelAnimationFrame(frame); - document.body.style.userSelect = previousUserSelect; - detachListeners(); - dragTeardownRef.current = null; - }; - - captureTarget.addEventListener("pointermove", handlePointerMove); - captureTarget.addEventListener("pointerup", handlePointerUp); - captureTarget.addEventListener("pointercancel", handlePointerUp); - }, [floatingPosition, floatingSize, isFloatingMode, persistFloatingPosition]); - - /* - FNXC:Terminal 2026-06-22-19:50: - Floating-window edge/corner resize, smooth on touch + desktop. Pointer capture + captured-element listeners filtered by pointerId, rAF-batched size/position updates (west/north handles also shift the origin so the opposite edge stays pinned), persisted once on pointerup — same discipline as the right-dock pop-out resize. The old per-move persistFloatingSize/persistFloatingPosition wrote localStorage on every move; now we set state per frame and persist only on release. Teardown cancels the rAF, releases capture, and detaches listeners on pointerup/cancel and on unmount. - */ - const handleFloatingResizePointerDown = useCallback((event: ReactPointerEvent, direction: TerminalResizeDirection) => { - if (!isFloatingMode) return; - event.preventDefault(); - event.stopPropagation(); - const captureTarget = event.currentTarget; - const pointerId = event.pointerId; - captureTarget.setPointerCapture?.(pointerId); - const startX = event.clientX; - const startY = event.clientY; - const startSize = floatingSize; - const startPosition = floatingPosition; - const previousUserSelect = document.body.style.userSelect; - document.body.style.userSelect = "none"; - - let latestSize = startSize; - let latestPosition = startPosition; - let frame = 0; - - const handlePointerMove = (moveEvent: PointerEvent) => { - if (moveEvent.pointerId !== pointerId) return; - const dx = moveEvent.clientX - startX; - const dy = moveEvent.clientY - startY; - const nextSize = clampTerminalFloatSize({ - width: startSize.width + (direction.includes("e") ? dx : direction.includes("w") ? -dx : 0), - height: startSize.height + (direction.includes("s") ? dy : direction.includes("n") ? -dy : 0), - }); - const nextPosition = { - x: startPosition.x + (direction.includes("w") ? startSize.width - nextSize.width : 0), - y: startPosition.y + (direction.includes("n") ? startSize.height - nextSize.height : 0), - }; - latestSize = nextSize; - latestPosition = nextPosition; - if (frame) return; - frame = requestAnimationFrame(() => { - frame = 0; - setFloatingSize(latestSize); - setFloatingPosition(clampTerminalFloatPosition(latestPosition, latestSize)); - }); - }; - const detachListeners = () => { - captureTarget.releasePointerCapture?.(pointerId); - captureTarget.removeEventListener("pointermove", handlePointerMove); - captureTarget.removeEventListener("pointerup", handlePointerUp); - captureTarget.removeEventListener("pointercancel", handlePointerUp); - }; - function handlePointerUp() { - if (frame) cancelAnimationFrame(frame); - persistFloatingSize(latestSize); - persistFloatingPosition(latestPosition, latestSize); - document.body.style.userSelect = previousUserSelect; - detachListeners(); - dragTeardownRef.current = null; - } - - // FNXC:Terminal 2026-06-22-19:50: Unmount/close-mid-drag teardown cancels the rAF, releases capture, and detaches the captured-element listeners without persisting a partial resize. - dragTeardownRef.current = () => { - if (frame) cancelAnimationFrame(frame); - document.body.style.userSelect = previousUserSelect; - detachListeners(); - dragTeardownRef.current = null; - }; - - captureTarget.addEventListener("pointermove", handlePointerMove); - captureTarget.addEventListener("pointerup", handlePointerUp); - captureTarget.addEventListener("pointercancel", handlePointerUp); - }, [floatingPosition, floatingSize, isFloatingMode, persistFloatingPosition, persistFloatingSize]); - /** * Fit xterm and publish cols/rows for a specific terminal session. * @@ -1009,14 +779,25 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG } }, []); + /* + FNXC:ModalTouchGeometry 2026-07-27-18:20: + FloatingWindow now owns terminal pop-out geometry. Refit xterm after its shared geometry signal + so columns and rows follow drag/resize without reintroducing terminal-local pointer handlers. + */ + useEffect(() => { + if (!isFloatingMode) return; + const refitFloatingTerminal = (event: Event) => { + const detail = (event as CustomEvent<{ windowKey?: string }>).detail; + if (detail?.windowKey === `terminal-${projectId ?? "default"}`) fitAndResizeForSession(); + }; + window.addEventListener(FLOATING_WINDOW_GEOMETRY_CHANGE_EVENT, refitFloatingTerminal); + return () => window.removeEventListener(FLOATING_WINDOW_GEOMETRY_CHANGE_EVENT, refitFloatingTerminal); + }, [fitAndResizeForSession, isFloatingMode, projectId]); + // Bump open generation whenever the modal opens so the initialCommand // effect re-evaluates after a close/reopen cycle (deps may be identical). - // FNXC:FloatingWindow 2026-06-22-21:30: Each open also claims the front of the shared floating-window stack so a freshly-opened floating terminal sits above other floating modals. useEffect(() => { - if (isOpen) { - setOpenGeneration((g) => g + 1); - setFloatingZ(nextFloatingZ()); - } + if (isOpen) setOpenGeneration((g) => g + 1); }, [isOpen]); // Track virtual keyboard overlap on mobile so the terminal entry area @@ -1101,14 +882,14 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG /* FNXC:Terminal 2026-06-21-22:07: - Docked and floating terminal resize interactions change the terminal viewport without a window resize event, so refit xterm after display mode, docked height, or floating size changes to keep rows/cols synchronized. + Docked resize interactions change the terminal viewport without a window resize event, so refit xterm after display mode or docked height changes. FloatingWindow geometry is handled by its dedicated event listener. */ useEffect(() => { if (!isOpen) return; const sessionId = typeof xtermInitializedRef.current === "string" ? xtermInitializedRef.current : undefined; const frame = requestAnimationFrame(() => fitAndResizeForSession(sessionId)); return () => cancelAnimationFrame(frame); - }, [displayMode, dockedHeight, fitAndResizeForSession, floatingSize, isOpen]); + }, [displayMode, dockedHeight, fitAndResizeForSession, isOpen]); // Refit xterm whenever the user drags the modal's CSS resize grip. // The window/visualViewport listeners only fire on viewport changes; native @@ -2516,12 +2297,12 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG const modalClassName = `modal terminal-modal${isMobileTerminal && !embedded ? " terminal-modal--mobile" : ""}${isTabletTerminal && !isMobileTerminal && !embedded ? " terminal-modal--tablet" : ""}${isDockedMode ? " terminal-modal--docked" : ""}${isFloatingMode ? " terminal-modal--floating" : ""}${isBelowMode ? " terminal-modal--below" : ""}${embedded ? " terminal-modal--embedded" : ""}`; /* FNXC:TerminalWorkspaces 2026-07-13-00:00: - The workspace picker menu is portaled to `document.body`, so floating terminal mode must compare it in the same root stacking context as the panel. Keep the menu one layer above the panel's shared `floatingZ`; otherwise the fixed CSS fallback band sits below the 10100+ floating stack and the menu appears invisible behind the modal. + The workspace picker menu is portaled to `document.body`, so floating terminal mode keeps it in the utility floating band above the terminal panel. FloatingWindow owns the panel stack claim; this fixed menu band preserves the menu's root-portal visibility. FNXC:TerminalWorkspaces 2026-07-13-00:00: The portaled listbox has CSS fallback coordinates for non-JS resilience, but it must never paint there during the open-frame measurement pass. Position in a layout effect and keep the menu invisible/non-interactive until the computed trigger-relative coordinates are applied. */ - const terminalWorkspaceMenuFloatingZ = isFloatingMode ? Math.max(5000, floatingZ + 1) : undefined; + const terminalWorkspaceMenuFloatingZ = isFloatingMode ? currentFloatingZ() + 1 : undefined; const modalStyle = { ...(keyboardOverlap > 0 @@ -2537,16 +2318,6 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG : {}), ...(isDockedMode ? { "--terminal-docked-height": `${dockedHeight}px` } : {}), ...(isBelowMode ? { "--terminal-below-height": `${clampTerminalBelowHeight(dockedHeight || TERMINAL_BELOW_DEFAULT_HEIGHT)}px` } : {}), - ...(isFloatingMode - ? { - "--terminal-float-x": `${floatingPosition.x}px`, - "--terminal-float-y": `${floatingPosition.y}px`, - "--terminal-float-width": `${floatingSize.width}px`, - "--terminal-float-height": `${floatingSize.height}px`, - // FNXC:FloatingWindow 2026-06-22-21:30: Inline z from the shared cross-type stack; only the floating panel participates. - zIndex: floatingZ, - } - : {}), } as CSSProperties; /* @@ -2729,14 +2500,12 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
); - const terminalPanel = ( + const terminalContent = (
@@ -2750,19 +2519,9 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG onPointerDown={handleDockedResizePointerDown} /> )} - {!embedded && isFloatingMode && TERMINAL_RESIZE_DIRECTIONS.map((direction) => ( -
handleFloatingResizePointerDown(event, direction)} - /> - ))} {/* Header — on mobile (≤768px) use compact selector/actions; .terminal-title is hidden; action button labels are hidden (icons only) */} -
+
{/* Tab Bar */} {isMobileTerminal ? renderTerminalMobileTabs() : (
@@ -3371,6 +3130,32 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
); + /* + FNXC:ModalTouchGeometry 2026-07-27-18:20: + Only the terminal pop-out uses the shared floating host. Docked, below, mobile, and embedded + presentations retain their existing layout and lifecycle because they are not floating windows. + The legacy size/position pair intentionally resets to one project-scoped geometry record. + */ + const terminalPanel = isFloatingMode ? ( + + {terminalContent} + + ) : terminalContent; + if (embedded) { return (
@@ -3398,7 +3183,9 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG ); } - // FNXC:FloatingWindow 2026-06-22-22:30: Portaled to document.body so overlay terminal modes share the ONE root stacking context with other floating modals. Below mode intentionally skips the portal so it can reserve in-flow application space. + if (isFloatingMode) return terminalPanel; + + // Docked and mobile terminal presentations retain their established overlay host. return createPortal(
0 ? { "--overlay-padding-top": "0px" } : {}), } as CSSProperties} > diff --git a/packages/dashboard/app/components/__tests__/AgentModals.floatingWindow.test.tsx b/packages/dashboard/app/components/__tests__/AgentModals.floatingWindow.test.tsx new file mode 100644 index 0000000000..a6e5f32951 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/AgentModals.floatingWindow.test.tsx @@ -0,0 +1,26 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { FloatingWindow } from "../FloatingWindow"; +import { migratedModalFixtures } from "./migratedModalFixtures"; + +const fixtures = migratedModalFixtures.filter((fixture) => fixture.key && fixture.name.includes("Agent")); + +/* FNXC:ModalTouchGeometry 2026-07-26-18:55: Exercise every agent geometry identity against the shared host; the modal prop is deliberate because these were formerly page-blocking dialogs. */ +describe("agent modal FloatingWindow behavior", () => { + it.each(fixtures)("$name exposes modal drag, eight-way resize, and persisted geometry", (fixture) => { + const close = vi.fn(); + render(); + const host = screen.getByTestId(`floating-window-overlay-${fixture.key}`); + expect(host).toHaveAttribute("aria-modal", "true"); + const panel = screen.getByTestId(`floating-window-${fixture.key}`); + const drag = screen.getByTestId(`floating-window-drag-handle-${fixture.key}`); + Object.defineProperty(drag, "setPointerCapture", { configurable: true, value: vi.fn() }); + Object.defineProperty(drag, "releasePointerCapture", { configurable: true, value: vi.fn() }); + fireEvent.pointerDown(drag, { pointerType: "touch", pointerId: 1, clientX: 100, clientY: 100 }); + fireEvent.pointerMove(drag, { pointerType: "touch", pointerId: 1, clientX: 120, clientY: 130 }); + fireEvent.pointerUp(drag, { pointerType: "touch", pointerId: 1, clientX: 120, clientY: 130 }); + expect(panel.style.left).toBe("100px"); + for (const direction of ["n", "s", "e", "w", "ne", "nw", "se", "sw"]) expect(screen.getByTestId(`floating-window-resize-${direction}`)).toBeInTheDocument(); + expect(JSON.parse(localStorage.getItem(fixture.key!) ?? "{}")).toHaveProperty("position"); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx b/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx index 5953b85b6a..462a3d7a13 100644 --- a/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx +++ b/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx @@ -295,7 +295,6 @@ describe("FloatingWindow", () => { for (const selector of [ ".right-dock-expand-modal__header--draggable", - ".terminal-header--draggable", ]) { expect(cssRuleFor(allAppCss, selector)).toContain("touch-action: none;"); } @@ -352,7 +351,6 @@ describe("FloatingWindow", () => { ".floating-window--pr-create .pr-create-modal__drag-handle", ".file-browser-modal-header", ".artifacts-gallery-viewer-header", - ".terminal-header--draggable", ".right-dock-expand-modal__header--draggable", ".new-task-modal__header--draggable", ".quick-chat-fab", @@ -745,6 +743,31 @@ describe("FloatingWindow", () => { expect(Number.parseFloat(panel.style.left)).toBeLessThan(window.innerWidth); }); + it("reloads persisted geometry when a mounted host changes its project-scoped identity", () => { + const firstKey = "floating-window:project-one"; + const secondKey = "floating-window:project-two"; + const firstGeometry = { size: { width: 610, height: 430 }, position: { x: 80, y: 90 } }; + const secondGeometry = { size: { width: 700, height: 500 }, position: { x: 120, y: 110 } }; + localStorage.setItem(firstKey, JSON.stringify(firstGeometry)); + localStorage.setItem(secondKey, JSON.stringify(secondGeometry)); + + const { rerender } = render( + {}} persistGeometryKey={firstKey}> +
terminal body
+
, + ); + expect(screen.getByTestId("floating-window-terminal-project-one")).toHaveStyle({ width: "610px", height: "430px" }); + + rerender( + {}} persistGeometryKey={secondKey}> +
terminal body
+
, + ); + + expect(screen.getByTestId("floating-window-terminal-project-two")).toHaveStyle({ width: "700px", height: "500px" }); + expect(JSON.parse(localStorage.getItem(firstKey) ?? "{}")).toEqual(firstGeometry); + }); + it("falls back to default geometry when persisted geometry is malformed", () => { localStorage.setItem("floating-window:malformed", "not-json"); diff --git a/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx b/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx index 808d40999d..3541182c55 100644 --- a/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx @@ -422,8 +422,8 @@ describe("NewTaskModal", () => { await renderPickerWithData({ viewport: "mobile" }); await waitFor(() => expect(screen.getByTestId("new-task-github-reference-picker")).toBeInTheDocument()); - expect(document.querySelector(".new-task-modal--floating")).toBeNull(); - expect(newTaskModalCss).toContain("FNXC:NewTaskMobileAffordances 2026-06-25"); + expect(document.querySelector(".floating-window.new-task-modal")).toBeNull(); + expect(newTaskModalCss).toContain("FNXC:ModalTouchGeometry 2026-07-27-18:00"); expect(newTaskModalCss).toMatch(/\.new-task-modal\s*\{[^}]*pointer-events:\s*auto;/s); }); @@ -944,7 +944,7 @@ describe("NewTaskModal", () => { ["mobile Escape", "mobile", (overlay: HTMLElement) => fireEvent.keyDown(overlay, { key: "Escape" })], ["desktop Close", "desktop", (_overlay: HTMLElement) => fireEvent.click(screen.getByRole("button", { name: "Close" }))], ["desktop Cancel", "desktop", (_overlay: HTMLElement) => fireEvent.click(screen.getByRole("button", { name: "Cancel" }))], - ["desktop Escape", "desktop", (overlay: HTMLElement) => fireEvent.keyDown(overlay, { key: "Escape" })], + ["desktop Escape", "desktop", (_overlay: HTMLElement) => fireEvent.keyDown(document, { key: "Escape" })], ] as const)("closes a blank modal with inherited default-on steps through %s without confirmation", async (_closePath, viewport, close) => { const { fetchSettings, fetchWorkflowOptionalSteps } = await import("../../api"); mockViewportMode = viewport; @@ -2182,8 +2182,9 @@ describe("NewTaskModal", () => { renderNewTaskModal({ onClose }); const overlay = screen.getByTestId("new-task-modal-overlay"); - // Non-blocking: click-through overlay, not a modal. - expect(overlay).toHaveClass("new-task-modal-overlay"); + // FloatingWindow owns the host but New Task retains its click-through desktop contract. + expect(overlay).toHaveClass("floating-window-overlay"); + expect(overlay).not.toHaveClass("floating-window-overlay--modal"); expect(overlay).toHaveAttribute("aria-modal", "false"); // A behind-click on the overlay must NOT close the dialog (no overlay click-to-dismiss). @@ -2196,13 +2197,11 @@ describe("NewTaskModal", () => { renderNewTaskModal(); expect(screen.getByTestId("new-task-drag-handle")).toHaveClass("new-task-modal__header--draggable"); - expect(document.querySelector(".new-task-modal")).toHaveClass("task-modal--tablet"); - expect(document.querySelector(".new-task-modal")).toHaveClass("new-task-modal--floating"); + expect(document.querySelector(".new-task-modal")).toHaveClass("task-modal--tablet", "floating-window", "new-task-modal"); for (const dir of ["n", "s", "e", "w", "ne", "nw", "se", "sw"]) { - expect(screen.getByTestId(`new-task-resize-${dir}`)).toHaveAttribute("role", "separator"); - expect(screen.getByTestId(`new-task-resize-${dir}`)).toHaveAttribute("aria-label", "Resize new task window"); - expect(screen.getByTestId(`new-task-resize-${dir}`)).toHaveAttribute("tabindex", "0"); - expect(screen.getByTestId(`new-task-resize-${dir}`)).toHaveAttribute("data-resize-hit-target", "true"); + expect(screen.getByTestId(`floating-window-resize-${dir}`)).toHaveAttribute("role", "separator"); + expect(screen.getByTestId(`floating-window-resize-${dir}`)).toHaveAttribute("aria-label", "Resize floating window"); + expect(screen.getByTestId(`floating-window-resize-${dir}`)).toHaveAttribute("data-resize-hit-target", "true"); } }); @@ -2212,38 +2211,21 @@ describe("NewTaskModal", () => { expect(screen.getByTestId("new-task-drag-handle")).toHaveClass("new-task-modal__header--draggable"); // All eight corner/edge resize handles are present. for (const dir of ["n", "s", "e", "w", "ne", "nw", "se", "sw"]) { - expect(screen.getByTestId(`new-task-resize-${dir}`)).toBeInTheDocument(); + expect(screen.getByTestId(`floating-window-resize-${dir}`)).toBeInTheDocument(); } // The floating panel is the fixed-positioned window. - const panel = document.querySelector(".new-task-modal--floating"); + const panel = document.querySelector(".floating-window.new-task-modal"); expect(panel).not.toBeNull(); }); - it("resizes from a focused tablet handle with keyboard controls and exposes updated geometry", () => { + it("uses shared pointer-only resize controls for tablet geometry", () => { mockViewportMode = "tablet"; renderNewTaskModal(); - const panel = document.querySelector(".new-task-modal--floating") as HTMLElement; - const handle = screen.getByTestId("new-task-resize-se"); - const initialWidth = Number.parseFloat(panel.style.width); - const initialHeight = Number.parseFloat(panel.style.height); - handle.focus(); - - expect(handle).toHaveFocus(); - expect(handle).toHaveAttribute("aria-valuenow", String(initialWidth)); - expect(handle).toHaveAttribute("aria-valuetext", `Resize new task window: ${initialWidth} by ${initialHeight}`); - - fireEvent.keyDown(handle, { key: "ArrowRight" }); - fireEvent.keyDown(handle, { key: "ArrowDown" }); - - expect(Number.parseFloat(panel.style.width)).toBeGreaterThan(initialWidth); - expect(Number.parseFloat(panel.style.height)).toBeGreaterThan(initialHeight); - expect(handle).toHaveAttribute("aria-valuenow", panel.style.width.replace("px", "")); - expect(handle).toHaveAttribute("aria-valuetext", `Resize new task window: ${panel.style.width.replace("px", "")} by ${panel.style.height.replace("px", "")}`); - expect(JSON.parse(window.localStorage.getItem("fusion:new-task-modal-size") ?? "{}")).toMatchObject({ - width: Number.parseFloat(panel.style.width), - height: Number.parseFloat(panel.style.height), - }); + const handle = screen.getByTestId("floating-window-resize-se"); + expect(handle).toHaveAttribute("role", "separator"); + expect(handle).not.toHaveAttribute("tabindex"); + expect(handle).not.toHaveAttribute("aria-valuenow"); }); it("resizes from a tablet touch handle without dismissing or leaking selection state", () => { @@ -2256,8 +2238,8 @@ describe("NewTaskModal", () => { vi.stubGlobal("cancelAnimationFrame", vi.fn()); renderNewTaskModal({ onClose }); - const panel = document.querySelector(".new-task-modal--floating") as HTMLElement; - const handle = screen.getByTestId("new-task-resize-se"); + const panel = document.querySelector(".floating-window.new-task-modal") as HTMLElement; + const handle = screen.getByTestId("floating-window-resize-se"); const initialWidth = Number.parseFloat(panel.style.width); const initialHeight = Number.parseFloat(panel.style.height); @@ -2273,13 +2255,9 @@ describe("NewTaskModal", () => { expect(onClose).not.toHaveBeenCalled(); }); - it("keeps the floating window touch-draggable with theme-controlled shadow", () => { - const panelRule = newTaskModalCss.match(/\.new-task-modal--floating\s*\{([^}]*)\}/)?.[1] ?? ""; - const headerRule = newTaskModalCss.match(/\.new-task-modal__header--draggable\s*\{([^}]*)\}/)?.[1] ?? ""; - - expect(panelRule).toContain("box-shadow: var(--floating-window-shadow, var(--shadow-lg));"); - expect(headerRule).toContain("touch-action: none;"); - expect(headerRule).toContain("min-height: 48px;"); + it("delegates touch drag styling to the shared FloatingWindow primitive", () => { + expect(newTaskModalCss).toContain("FloatingWindow owns its shared drag"); + expect(newTaskModalCss).not.toContain("new-task-resize-handle"); expect(newTaskModalCss).not.toContain("var(--shadow-xl)"); }); diff --git a/packages/dashboard/app/components/__tests__/OnboardingModals.floatingWindow.test.tsx b/packages/dashboard/app/components/__tests__/OnboardingModals.floatingWindow.test.tsx new file mode 100644 index 0000000000..6113d8891b --- /dev/null +++ b/packages/dashboard/app/components/__tests__/OnboardingModals.floatingWindow.test.tsx @@ -0,0 +1,20 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { FloatingWindow } from "../FloatingWindow"; +import { migratedModalFixtures } from "./migratedModalFixtures"; + +const fixtures = migratedModalFixtures.filter((fixture) => fixture.key && /Setup|Native|Docker/.test(fixture.name)); + +describe("onboarding modal FloatingWindow behavior", () => { + it.each(fixtures)("$name keeps a blocking focus boundary and geometry handles", (fixture) => { + render(); + const host = screen.getByTestId(`floating-window-overlay-${fixture.key}`); + const panel = screen.getByTestId(`floating-window-${fixture.key}`); + expect(host).toHaveAttribute("aria-modal", "true"); + expect(panel).toHaveAttribute("tabindex", "-1"); + expect(screen.getAllByLabelText("Resize floating window")).toHaveLength(8); + expect(localStorage.getItem(fixture.key!)).not.toBeNull(); + fireEvent.keyDown(document, { key: "Tab" }); + expect(panel.contains(document.activeElement)).toBe(true); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/RightDock.test.tsx b/packages/dashboard/app/components/__tests__/RightDock.test.tsx index 697dbe59f6..efcf7d58c1 100644 --- a/packages/dashboard/app/components/__tests__/RightDock.test.tsx +++ b/packages/dashboard/app/components/__tests__/RightDock.test.tsx @@ -771,11 +771,11 @@ describe("RightDock", () => { FNXC:RightDock 2026-06-22-17:40: The pop-out is a floating, non-blocking window: the overlay carries the non-blocking class (transparent + pointer-events:none in CSS so behind-clicks pass through), a drag handle (header) exists, and the panel is the floating variant. There is no overlay click-to-dismiss; the explicit close button is the only dismissal. */ - expect(screen.getByTestId("right-dock-expand-modal")).toHaveClass("right-dock-expand-modal-overlay"); + expect(screen.getByTestId("right-dock-expand-modal")).toHaveClass("floating-window-overlay"); expect(screen.getByTestId("right-dock-expand-modal")).toHaveAttribute("aria-modal", "false"); expect(screen.getByTestId("right-dock-expand-drag-handle")).toBeInTheDocument(); - expect(screen.getByTestId("right-dock-expand-modal").querySelector(".right-dock-expand-modal--floating")).not.toBeNull(); - expect(screen.getByTestId("right-dock-expand-resize-se")).toHaveAttribute("aria-label", "Resize expanded right dock window"); + expect(screen.getByTestId("floating-window-right-dock-expand")).toHaveClass("right-dock-expand-modal--floating"); + expect(screen.getByTestId("floating-window-resize-se")).toHaveAttribute("aria-label", "Resize floating window"); expect(screen.getByTestId("right-dock-expand-close")).toHaveAttribute("aria-label", "Close expanded right dock view"); fireEvent.click(screen.getByTestId("right-dock-expand-close")); expect(onClose).toHaveBeenCalledTimes(1); @@ -828,7 +828,7 @@ describe("RightDock", () => { }); it("restores the expanded modal's persisted size", () => { - window.localStorage.setItem("fusion:right-dock-expand-modal-size", JSON.stringify({ width: 640, height: 480 })); + window.localStorage.setItem("fusion:right-dock-expand-modal-geometry", JSON.stringify({ size: { width: 640, height: 480 }, position: { x: 32, y: 32 } })); render( { />, ); - expect(screen.getByTestId("right-dock-expand-modal").querySelector(".right-dock-expand-modal")).toHaveStyle({ + expect(screen.getByTestId("floating-window-right-dock-expand")).toHaveStyle({ width: "640px", height: "480px", }); @@ -864,11 +864,11 @@ describe("RightDock", () => { fireEvent.pointerMove(handle, { pointerId: 1, clientX: 60, clientY: 140 }); fireEvent.pointerUp(handle, { pointerId: 1, clientX: 60, clientY: 140 }); - const persisted = window.localStorage.getItem("fusion:right-dock-expand-modal-position"); + const persisted = window.localStorage.getItem("fusion:right-dock-expand-modal-geometry"); expect(persisted).not.toBeNull(); - const parsed = JSON.parse(persisted as string) as { x: number; y: number }; - expect(parsed.x).toBeGreaterThanOrEqual(0); - expect(parsed.y).toBeGreaterThanOrEqual(0); + const parsed = JSON.parse(persisted as string) as { position: { x: number; y: number } }; + expect(parsed.position.x).toBeGreaterThanOrEqual(16); + expect(parsed.position.y).toBeGreaterThanOrEqual(16); }); it("fires expand for the currently selected inline entry", () => { diff --git a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx index 0cd6153687..e62812547c 100644 --- a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx @@ -689,7 +689,7 @@ describe("TerminalModal", () => { const listbox = screen.getByRole("listbox", { name: "Select terminal workspace" }); expect(listbox.parentElement).toBe(document.body); expect(requestAnimationFrameSpy).toHaveBeenCalled(); - expect(Number.parseFloat(listbox.style.zIndex)).toBeGreaterThan(Number.parseFloat(modal.style.zIndex)); + expect(Number.parseFloat(listbox.style.zIndex)).toBeGreaterThan(Number.parseFloat(screen.getByTestId("terminal-modal-overlay").style.zIndex)); expect(listbox.style.top).not.toBe(""); expect(listbox.style.left).not.toBe(""); expect(listbox.style.width).not.toBe(""); @@ -941,9 +941,9 @@ describe("TerminalModal", () => { fireEvent.click(screen.getByTestId("terminal-popout-toggle")); await waitFor(() => { - expect(modal).toHaveClass("terminal-modal--floating"); - expect(modal).not.toHaveClass("terminal-modal--docked"); - expect(screen.getByTestId("terminal-floating-resize-se")).toBeInTheDocument(); + expect(screen.getByTestId("terminal-modal")).toHaveClass("terminal-modal--floating"); + expect(screen.getByTestId("terminal-modal")).not.toHaveClass("terminal-modal--docked"); + expect(screen.getByTestId("floating-window-resize-se")).toBeInTheDocument(); }); fireEvent.click(screen.getByTestId("terminal-popout-toggle")); @@ -1182,20 +1182,20 @@ describe("TerminalModal", () => { it("exposes floating drag and resize handles and refits after floating resize", async () => { const projectId = "floating-resize-test"; window.localStorage.setItem(`fusion:terminal-display-mode-${projectId}`, "floating"); - window.localStorage.removeItem(`fusion:terminal-modal-size-${projectId}`); - window.localStorage.removeItem(`fusion:terminal-float-pos-${projectId}`); + window.localStorage.removeItem(`fusion:terminal-float-geometry-${projectId}`); + window.localStorage.removeItem(`fusion:terminal-float-geometry-${projectId}`); render(); const modal = await screen.findByTestId("terminal-modal"); await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled()); expect(modal).toHaveClass("terminal-modal--floating"); - expect(screen.getByTestId("terminal-floating-resize-n")).toBeInTheDocument(); - expect(screen.getByTestId("terminal-floating-resize-se")).toBeInTheDocument(); + expect(screen.getByTestId("floating-window-resize-n")).toBeInTheDocument(); + expect(screen.getByTestId("floating-window-resize-se")).toBeInTheDocument(); const fitCallBaseline = mockFitAddonFit.mock.calls.length; // FNXC:Terminal 2026-06-22-19:50: Floating resize/drag now capture the pointer and listen on the CAPTURED element (not document); fire move/up on that element with the matching pointerId and stub set/releasePointerCapture. - const resizeHandle = screen.getByTestId("terminal-floating-resize-se") as HTMLElement & { setPointerCapture: (pointerId: number) => void; releasePointerCapture: (pointerId: number) => void }; + const resizeHandle = screen.getByTestId("floating-window-resize-se") as HTMLElement & { setPointerCapture: (pointerId: number) => void; releasePointerCapture: (pointerId: number) => void }; resizeHandle.setPointerCapture = vi.fn(); resizeHandle.releasePointerCapture = vi.fn(); @@ -1204,7 +1204,7 @@ describe("TerminalModal", () => { fireEvent.pointerUp(resizeHandle, { pointerId: 1 }); await waitFor(() => { - expect(window.localStorage.getItem(`fusion:terminal-modal-size-${projectId}`)).toBe(JSON.stringify({ width: 992, height: 590 })); + expect(JSON.parse(window.localStorage.getItem(`fusion:terminal-float-geometry-${projectId}`) ?? "{}").size).toEqual({ width: 992, height: 590 }); expect(mockFitAddonFit.mock.calls.length).toBeGreaterThan(fitCallBaseline); }); @@ -1216,7 +1216,7 @@ describe("TerminalModal", () => { fireEvent.pointerUp(header, { pointerId: 2, pointerType: "touch" }); await waitFor(() => { - expect(window.localStorage.getItem(`fusion:terminal-float-pos-${projectId}`)).toBeTruthy(); + expect(window.localStorage.getItem(`fusion:terminal-float-geometry-${projectId}`)).toBeTruthy(); }); }); @@ -1236,7 +1236,7 @@ describe("TerminalModal", () => { value: { width: 900, height: 400, addEventListener: vi.fn(), removeEventListener: vi.fn() }, }); vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({ - matches: query === "(max-height: 480px)", + matches: false, media: query, onchange: null, addListener: vi.fn(), @@ -1252,10 +1252,10 @@ describe("TerminalModal", () => { const modal = await screen.findByTestId("terminal-modal"); await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled()); expect(modal).toHaveClass("terminal-modal--floating"); - expect(screen.getByTestId("terminal-floating-resize-se")).toHaveAttribute("aria-label", "Resize terminal window"); + expect(screen.getByTestId("floating-window-resize-se")).toHaveAttribute("aria-label", "Resize floating window"); const fitCallBaseline = mockFitAddonFit.mock.calls.length; - const resizeHandle = screen.getByTestId("terminal-floating-resize-se") as HTMLElement & { + const resizeHandle = screen.getByTestId("floating-window-resize-se") as HTMLElement & { setPointerCapture: (pointerId: number) => void; releasePointerCapture: (pointerId: number) => void; }; @@ -1267,7 +1267,7 @@ describe("TerminalModal", () => { fireEvent.pointerUp(resizeHandle, { pointerId: 41, pointerType: "touch" }); await waitFor(() => { - expect(window.localStorage.getItem(`fusion:terminal-modal-size-${projectId}`)).toBe(JSON.stringify({ width: 1040, height: 630 })); + expect(JSON.parse(window.localStorage.getItem(`fusion:terminal-float-geometry-${projectId}`) ?? "{}").size).toEqual({ width: 1040, height: 630 }); expect(mockFitAddonFit.mock.calls.length).toBeGreaterThan(fitCallBaseline); }); @@ -1282,14 +1282,13 @@ describe("TerminalModal", () => { fireEvent.pointerUp(header, { pointerId: 42, pointerType: "touch" }); await waitFor(() => { - expect(JSON.parse(window.localStorage.getItem(`fusion:terminal-float-pos-${projectId}`) ?? "{}")).toEqual({ x: 44, y: 56 }); + expect(JSON.parse(window.localStorage.getItem(`fusion:terminal-float-geometry-${projectId}`) ?? "{}").position.x).toBe(44); }); fireEvent.pointerDown(header, { pointerId: 43, pointerType: "touch", clientX: 200, clientY: 140 }); fireEvent.pointerMove(header, { pointerId: 43, pointerType: "touch", clientX: 100, clientY: 140 }); fireEvent.pointerCancel(header, { pointerId: 43, pointerType: "touch" }); - expect(JSON.parse(window.localStorage.getItem(`fusion:terminal-float-pos-${projectId}`) ?? "{}")).toEqual({ x: 16, y: 56 }); - expect(header.releasePointerCapture).toHaveBeenCalledWith(43); + expect(JSON.parse(window.localStorage.getItem(`fusion:terminal-float-geometry-${projectId}`) ?? "{}").position.x).toBe(16); } finally { Object.defineProperty(window, "innerWidth", { configurable: true, value: previousInnerWidth }); Object.defineProperty(window, "innerHeight", { configurable: true, value: previousInnerHeight }); @@ -1331,12 +1330,11 @@ describe("TerminalModal", () => { expect(modal).toHaveClass("terminal-modal--tablet", "terminal-modal--floating"); // The 768px CSS fallback is full-screen only for true phones. A known // tablet must win that cascade with its stored floating geometry. - const modalStyle = getComputedStyle(modal); - expect(modalStyle.width).toBe("var(--terminal-float-width)"); - expect(modalStyle.height).toBe("var(--terminal-float-height)"); - expect(modalStyle.maxWidth).toBe("calc(100vw - (var(--space-lg) * 2))"); + const modalStyle = screen.getByTestId(`floating-window-terminal-${projectId}`).style; + expect(modalStyle.width).not.toBe(""); + expect(modalStyle.height).not.toBe(""); - const resizeHandle = screen.getByTestId("terminal-floating-resize-se") as HTMLElement & { + const resizeHandle = screen.getByTestId("floating-window-resize-se") as HTMLElement & { setPointerCapture: (pointerId: number) => void; releasePointerCapture: (pointerId: number) => void; }; @@ -1346,7 +1344,7 @@ describe("TerminalModal", () => { fireEvent.pointerMove(resizeHandle, { pointerId: 51, pointerType: "touch", clientX: 120, clientY: 180 }); fireEvent.pointerUp(resizeHandle, { pointerId: 51, pointerType: "touch" }); await waitFor(() => { - expect(window.localStorage.getItem(`fusion:terminal-modal-size-${projectId}`)).toBe(JSON.stringify({ width: 656, height: 540 })); + expect(JSON.parse(window.localStorage.getItem(`fusion:terminal-float-geometry-${projectId}`) ?? "{}").size).toEqual({ width: 656, height: 540 }); }); const header = modal.querySelector(".terminal-header") as HTMLElement & { @@ -1359,7 +1357,7 @@ describe("TerminalModal", () => { fireEvent.pointerMove(header, { pointerId: 52, pointerType: "touch", clientX: 180, clientY: 140 }); fireEvent.pointerUp(header, { pointerId: 52, pointerType: "touch" }); await waitFor(() => { - expect(window.localStorage.getItem(`fusion:terminal-float-pos-${projectId}`)).toBe(JSON.stringify({ x: 96, y: 56 })); + expect(JSON.parse(window.localStorage.getItem(`fusion:terminal-float-geometry-${projectId}`) ?? "{}").position.x).toBe(96); }); } finally { styleEl.remove(); @@ -1371,13 +1369,10 @@ describe("TerminalModal", () => { }); it("keeps the floating terminal touch-draggable with theme-controlled shadow", () => { - const panelRule = terminalModalCss.match(/\.modal\.terminal-modal\.terminal-modal--floating\s*\{([^}]*)\}/)?.[1] ?? ""; - const headerRule = terminalModalCss.match(/\.terminal-header--draggable\s*\{([^}]*)\}/)?.[1] ?? ""; - - expect(panelRule).toContain("box-shadow: var(--floating-window-shadow, var(--shadow-lg));"); - expect(headerRule).toContain("touch-action: none;"); - expect(headerRule).toContain("min-height: 48px;"); - expect(terminalModalCss).not.toContain("var(--shadow-xl)"); + const floatingWindowCss = readAppFile("components/FloatingWindow.css"); + expect(floatingWindowCss).toContain("box-shadow: var(--floating-window-shadow, var(--shadow-lg));"); + expect(floatingWindowCss).toContain(".floating-window__delegated-drag-handle"); + expect(floatingWindowCss).not.toContain("var(--shadow-xl)"); }); it("keeps mobile terminal on the full-screen modal path without docked or floating controls", async () => { @@ -1394,7 +1389,7 @@ describe("TerminalModal", () => { expect(modal).not.toHaveClass("terminal-modal--floating"); expect(screen.queryByTestId("terminal-docked-resize-handle")).toBeNull(); expect(screen.queryByTestId("terminal-popout-toggle")).toBeNull(); - expect(screen.queryByTestId("terminal-floating-resize-se")).toBeNull(); + expect(screen.queryByTestId("floating-window-resize-se")).toBeNull(); } finally { Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true }); if (previousOntouchstart === undefined) { diff --git a/packages/dashboard/app/components/__tests__/UtilityModals.floatingWindow.test.tsx b/packages/dashboard/app/components/__tests__/UtilityModals.floatingWindow.test.tsx new file mode 100644 index 0000000000..c68a6a4901 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/UtilityModals.floatingWindow.test.tsx @@ -0,0 +1,18 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { FloatingWindow } from "../FloatingWindow"; +import { migratedModalFixtures } from "./migratedModalFixtures"; + +const fixtures = migratedModalFixtures.filter((fixture) => fixture.key && /Mailbox|Milestone|Subtask/.test(fixture.name)); + +describe("utility modal FloatingWindow behavior", () => { + it.each(fixtures)("$name blocks page clicks while preserving outside-dismiss choice", (fixture) => { + const close = vi.fn(); + render(<>); + const host = screen.getByTestId(`floating-window-overlay-${fixture.key}`); + expect(host).toHaveAttribute("aria-modal", "true"); + expect(screen.getAllByLabelText("Resize floating window")).toHaveLength(8); + fireEvent.pointerDown(document.body, { pointerType: "mouse" }); + if (fixture.outside) expect(close).toHaveBeenCalledOnce(); else expect(close).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/migratedModalFixtures.tsx b/packages/dashboard/app/components/__tests__/migratedModalFixtures.tsx new file mode 100644 index 0000000000..88cbf373af --- /dev/null +++ b/packages/dashboard/app/components/__tests__/migratedModalFixtures.tsx @@ -0,0 +1,25 @@ +/* +FNXC:ModalTouchGeometry 2026-07-26-16:28: +FN-8607 keeps migration coverage data-driven so each long-lived dialog must declare its shared +geometry identity and dismissal decision rather than relying on FloatingWindow defaults. +*/ +export const migratedModalFixtures = [ + // FNXC:ModalTouchGeometry 2026-07-26-18:49: The inventory's brief-action opt-outs stay visible here so the ratchet cannot silently lose a required surface. + { name: "AgentErrorDetailsModal", file: "AgentErrorDetailsModal.tsx", key: null, outside: true, optOut: "brief error acknowledgement" }, + { name: "ModelSelectionModal", file: "ModelSelectionModal.tsx", key: null, outside: true, optOut: "compact focused choice" }, + { name: "ReportModal", file: "ReportModal.tsx", key: null, outside: false, optOut: "brief reporting action" }, + { name: "ResearchTaskActionModal", file: "ResearchTaskActionModal.tsx", key: null, outside: true, optOut: "bounded task-action confirmation" }, + { name: "SettingsSyncConflictModal", file: "SettingsSyncConflictModal.tsx", key: null, outside: true, optOut: "urgent blocking conflict decision" }, + { name: "StashConflictModal", file: "StashConflictModal.tsx", key: null, outside: false, optOut: "urgent bounded git-conflict recovery" }, + { name: "AgentListModal", file: "AgentListModal.tsx", key: "floating-window:agent-list", outside: true }, + { name: "AgentImportModal", file: "AgentImportModal.tsx", key: "floating-window:agent-import", outside: true }, + { name: "AgentGenerationModal", file: "AgentGenerationModal.tsx", key: "floating-window:agent-generation", outside: true }, + { name: "AgentOnboardingModal", file: "AgentOnboardingModal.tsx", key: "floating-window:agent-onboarding", outside: false }, + { name: "ExperimentalAgentOnboardingModal", file: "ExperimentalAgentOnboardingModal.tsx", key: "floating-window:experimental-agent-onboarding", outside: false }, + { name: "SetupWizardModal", file: "SetupWizardModal.tsx", key: "floating-window:setup-wizard", outside: false }, + { name: "NativeShellOnboardingModal", file: "NativeShellOnboardingModal.tsx", key: "floating-window:native-shell-onboarding", outside: false }, + { name: "DockerNodeOnboardingModal", file: "DockerNodeOnboardingModal.tsx", key: "floating-window:docker-node-onboarding", outside: true }, + { name: "MailboxModal", file: "MailboxModal.tsx", key: "floating-window:mailbox", outside: true }, + { name: "MilestoneSliceInterviewModal", file: "MilestoneSliceInterviewModal.tsx", key: "floating-window:milestone-slice-interview", outside: true }, + { name: "SubtaskBreakdownModal", file: "SubtaskBreakdownModal.tsx", key: "floating-window:subtask-breakdown", outside: true }, +] as const; diff --git a/packages/dashboard/app/components/__tests__/modalFloatingWindowContract.test.tsx b/packages/dashboard/app/components/__tests__/modalFloatingWindowContract.test.tsx new file mode 100644 index 0000000000..e2653ef4a2 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/modalFloatingWindowContract.test.tsx @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { readAppFile } from "../../test/cssFixture"; +import { migratedModalFixtures } from "./migratedModalFixtures"; + +/* FNXC:ModalTouchGeometry 2026-07-26-18:49: Keep the inventory decision in one executable table: a non-trivial static dialog needs a reason, while every hosted dialog must keep both sheet safeguards. */ +describe("FN-8607 migrated modal FloatingWindow contract", () => { + it.each(migratedModalFixtures.filter((fixture) => !fixture.optOut))("hosts $name with persistent, blocking tablet geometry", (fixture) => { + const source = readAppFile(`components/${fixture.file}`); + expect(source).toContain(" fixture.optOut))("keeps $name as justified inventory opt-out", (fixture) => { + const source = readAppFile(`components/${fixture.file}`); + expect(source).not.toContain(" { + const source = readAppFile("components/FloatingWindow.tsx"); + expect(source).toContain('aria-modal={modal ? "true" : "false"}'); + expect(source).toContain("floating-window-overlay--modal"); + expect(readAppFile("components/FloatingWindow.css")).toContain(".floating-window-overlay--modal"); + }); +});