FN-8607: migrate shared modals to FloatingWindow

Standardize agent, onboarding, and utility modal behavior on the shared FloatingWindow contract.

- Migrate modal hosts to shared geometry, sheet, dismissal, and focus behavior.
- Make FloatingWindow the sole owner of modal ARIA semantics and protect portal-safe dismissal surfaces.
- Cover migrated modal contracts and document the responsive sheet requirements.

Files changed:
 docs/dashboard-guide.md                            |   6 +-
 packages/dashboard/app/components/AgentGenerationModal.tsx        |   8 +-
 packages/dashboard/app/components/AgentImportModal.tsx  |   4 +-
 packages/dashboard/app/components/AgentListModal.tsx    |   4 +-
 packages/dashboard/app/components/AgentOnboardingModal.tsx        |   4 +-
 packages/dashboard/app/components/DockerNodeOnboardingModal.tsx   |   4 +-
 packages/dashboard/app/components/ExperimentalAgentOnboardingModal.tsx |   4 +-
 packages/dashboard/app/components/FloatingWindow.css    |  36 ++++--
 packages/dashboard/app/components/FloatingWindow.tsx    |  12 ++
 packages/dashboard/app/components/MilestoneSliceInterviewModal.tsx |   2 +-
 packages/dashboard/app/components/NativeShellOnboardingModal.tsx  |   2 +-
 packages/dashboard/app/components/SetupWizardModal.tsx  |   3 +-
 packages/dashboard/app/components/SubtaskBreakdownModal.tsx       |   2 +-
 packages/dashboard/app/components/__tests__/AgentModals.floatingWindow.test.tsx | 134 ++++++++++++++++++---
 packages/dashboard/app/components/__tests__/OnboardingModals.floatingWindow.test.tsx | 128 +++++++++++++++++---
 packages/dashboard/app/components/__tests__/UtilityModals.floatingWindow.test.tsx | 124 +++++++++++++++++--
 packages/dashboard/app/components/__tests__/migratedModalFixtures.tsx |  59 ++++++---
 packages/dashboard/app/components/__tests__/modalFloatingWindowContract.test.tsx |  20 ++-
 18 files changed, 458 insertions(+), 98 deletions(-)

Fusion-Task-Id: FN-8607

Fusion-Task-Lineage: 11ba6a9c-ce89-4358-83c4-a15ef15b6128

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-26 18:57:02 -07:00
parent bdfa5186c4
commit 89b4621a93
18 changed files with 458 additions and 98 deletions

View File

@@ -108,7 +108,7 @@ Core/workflow FloatingWindow modals use `persistGeometryKey="floating-window:<wi
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.
`closeOnOutsidePointerDown` defaults to **off**. A modal that previously dismissed on its backdrop must pass it explicitly, while first-run/blocking flows must omit it. The `FloatingWindow` panel is the sole `role="dialog"`/`aria-modal` owner; hosted content must not nest a second dialog. Its backdrop remains a real opt-in outside-pointer dismissal target, while nested dialogs and body-portaled menus are safe surfaces. 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.
@@ -2327,7 +2327,9 @@ its drag handle and supplies `hideHeader`, `dragHandleSelector`, `className`, `d
`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
shared touch geometry. The variant class must be included in the shared sheet reset: suspension of
geometry and removal of handles are insufficient unless the host and its content fill the phone or
short-viewport sheet. `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 |

View File

@@ -159,13 +159,9 @@ export function AgentGenerationModal({
roleDescription.trim().length <= MAX_ROLE_LENGTH;
return (
<FloatingWindow windowKey="agent-generation" title={t("agents.generation.title", "Generate Agent")} ariaLabel={t("agents.generateWithAiLabel", "Generate agent with AI")} onClose={handleCancel} hideHeader dragHandleSelector=".agent-generation-modal .agent-dialog-header" className="floating-window--agent-generation" defaultSize={{ width: 640, height: 560 }} minSize={{ width: 400, height: 300 }} persistGeometryKey="floating-window:agent-generation" suspendGeometryPersistenceOnMobile suspendGeometryPersistenceOnShortViewport closeOnOutsidePointerDown modal>
<FloatingWindow windowKey="agent-generation" modal title={t("agents.generation.title", "Generate Agent")} ariaLabel={t("agents.generateWithAiLabel", "Generate agent with AI")} onClose={handleCancel} hideHeader dragHandleSelector=".agent-generation-modal .agent-dialog-header" className="floating-window--agent-generation" defaultSize={{ width: 640, height: 560 }} minSize={{ width: 400, height: 300 }} persistGeometryKey="floating-window:agent-generation" suspendGeometryPersistenceOnMobile suspendGeometryPersistenceOnShortViewport closeOnOutsidePointerDown>
{/* FNXC:ModalTouchGeometry 2026-07-26-16:07: Generation can dwell while AI responds; persist shared geometry and preserve overlay dismissal. */}
<div className="agent-dialog agent-generation-modal"
role="dialog"
aria-modal="true"
aria-label={t("agents.generateWithAiLabel", "Generate agent with AI")}
>
<div className="agent-dialog agent-generation-modal">
{/* Header */}
<div className="agent-dialog-header">
<span className="agent-dialog-header-title">

View File

@@ -471,9 +471,9 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
if (!isOpen) return null;
return (
<FloatingWindow windowKey="agent-import" title={t("agents.importAgents", "Import Agents")} ariaLabel={t("agents.importAgents", "Import agents")} onClose={handleClose} hideHeader dragHandleSelector=".agent-import-dialog .agent-dialog-header" className="floating-window--agent-import" defaultSize={{ width: 720, height: 640 }} minSize={{ width: 420, height: 320 }} persistGeometryKey="floating-window:agent-import" suspendGeometryPersistenceOnMobile suspendGeometryPersistenceOnShortViewport closeOnOutsidePointerDown modal>
<FloatingWindow windowKey="agent-import" modal title={t("agents.importAgents", "Import Agents")} ariaLabel={t("agents.importAgents", "Import agents")} onClose={handleClose} hideHeader dragHandleSelector=".agent-import-dialog .agent-dialog-header" className="floating-window--agent-import" defaultSize={{ width: 720, height: 640 }} minSize={{ width: 420, height: 320 }} persistGeometryKey="floating-window:agent-import" suspendGeometryPersistenceOnMobile suspendGeometryPersistenceOnShortViewport closeOnOutsidePointerDown>
{/* FNXC:ModalTouchGeometry 2026-07-26-16:07: Import mapping is reflowable, so FloatingWindow owns tablet touch geometry and retains prior outside dismissal. */}
<div className="agent-dialog agent-import-dialog" role="dialog" aria-modal="true" aria-label={t("agents.importAgents", "Import agents")}>
<div className="agent-dialog agent-import-dialog">
{/* Header */}
<div className="agent-dialog-header">
<span className="agent-dialog-header-title">{t("agents.importAgents", "Import Agents")}</span>

View File

@@ -287,9 +287,9 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
if (!isOpen) return null;
return (
<FloatingWindow windowKey="agent-list" title={t("agents.modalTitle", "Agents")} ariaLabel={t("agents.modalTitle", "Agents")} onClose={onClose} hideHeader dragHandleSelector=".agent-list-modal .modal-header" className="floating-window--agent-list" defaultSize={{ width: 900, height: 680 }} minSize={{ width: 480, height: 360 }} persistGeometryKey="floating-window:agent-list" suspendGeometryPersistenceOnMobile suspendGeometryPersistenceOnShortViewport closeOnOutsidePointerDown modal>
<FloatingWindow windowKey="agent-list" modal title={t("agents.modalTitle", "Agents")} ariaLabel={t("agents.modalTitle", "Agents")} onClose={onClose} hideHeader dragHandleSelector=".agent-list-modal .modal-header" className="floating-window--agent-list" defaultSize={{ width: 900, height: 680 }} minSize={{ width: 480, height: 360 }} persistGeometryKey="floating-window:agent-list" suspendGeometryPersistenceOnMobile suspendGeometryPersistenceOnShortViewport closeOnOutsidePointerDown>
{/* 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. */}
<div className="modal modal--wide agent-list-modal" role="dialog" aria-modal="true">
<div className="modal modal--wide agent-list-modal">
<div className="modal-header">
<h2 className="modal-title">
<Bot size={20} />

View File

@@ -192,9 +192,9 @@ export function AgentOnboardingModal({ isOpen, onClose, onCreated, addToast, pro
};
return (
<FloatingWindow windowKey="agent-onboarding" title={t("agents.onboarding.title", "Agent Onboarding")} ariaLabel={t("agents.onboarding.title", "Agent Onboarding")} onClose={() => 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>
<FloatingWindow windowKey="agent-onboarding" modal title={t("agents.onboarding.title", "Agent Onboarding")} ariaLabel={t("agents.onboarding.title", "Agent Onboarding")} onClose={() => 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>
{/* FNXC:ModalTouchGeometry 2026-07-26-16:07: First-run onboarding remains blocking: omit outside dismissal while sharing tablet geometry and suspending every sheet viewport. */}
<div className="modal modal-lg agent-onboarding-modal" role="dialog" aria-modal="true" aria-label={t("agents.onboarding.title", "Agent Onboarding")}>
<div className="modal modal-lg agent-onboarding-modal">
<div className="modal-header">
<h3>{t("agents.onboarding.title", "Agent Onboarding")}</h3>
<button className="modal-close" onClick={() => void handleClose()} aria-label={t("common.close", "Close")}>×</button>

View File

@@ -212,9 +212,9 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
if (!isOpen) return null;
return (
<FloatingWindow windowKey="docker-node-onboarding" title={t("docker.titles.provisionNode", "Provision Docker Node")} ariaLabel={t("docker.ariaLabels.modal", "Docker node onboarding")} onClose={() => { 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>
<FloatingWindow windowKey="docker-node-onboarding" modal title={t("docker.titles.provisionNode", "Provision Docker Node")} ariaLabel={t("docker.ariaLabels.modal", "Docker node onboarding")} onClose={() => { 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>
{/* FNXC:ModalTouchGeometry 2026-07-26-16:22: Provisioning retains outside dismissal only before submit; the guarded host close protects an in-flight node creation. */}
<div className="modal docker-onboarding" role="dialog" aria-modal="true" aria-label={t("docker.ariaLabels.modal", "Docker node onboarding")}>
<div className="modal docker-onboarding">
<div className="modal-header">
<h3>{t("docker.titles.provisionNode", "Provision Docker Node")}</h3>
<button className="modal-close" onClick={closeModal} disabled={submitting} aria-label={t("docker.ariaLabels.closeModal", "Close onboarding modal")}>

View File

@@ -185,9 +185,9 @@ export function ExperimentalAgentOnboardingModal({
};
return (
<FloatingWindow windowKey="experimental-agent-onboarding" title={t("agents.onboarding.title", "AI Interview")} ariaLabel={t("agents.onboarding.dialogLabel", "AI Interview")} onClose={() => 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>
<FloatingWindow windowKey="experimental-agent-onboarding" modal title={t("agents.onboarding.title", "AI Interview")} ariaLabel={t("agents.onboarding.dialogLabel", "AI Interview")} onClose={() => 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>
{/* FNXC:ModalTouchGeometry 2026-07-26-16:07: Experimental onboarding remains a blocking flow; no outside-dismiss opt-in accompanies shared geometry. */}
<div className="modal modal-lg experimental-agent-onboarding-modal" role="dialog" aria-modal="true" aria-label={t("agents.onboarding.dialogLabel", "AI Interview")}>
<div className="modal modal-lg experimental-agent-onboarding-modal">
<div className="modal-header">
<h3>{t("agents.onboarding.title", "AI Interview")}</h3>
<button className="modal-close" onClick={() => void handleClose()} aria-label={t("common.closeAriaLabel", "Close")}>×</button>

View File

@@ -308,21 +308,28 @@ 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.
FNXC:ModalTouchGeometry 2026-07-26-17:31:
FN-8607's Agent List and Setup Wizard suspend geometry on true phones, so their migrated
FloatingWindow hosts must also fill the sheet viewport rather than retaining desktop dimensions.
FNXC:ModalTouchGeometry 2026-07-28-14:30:
FN-8607's agent, onboarding, mailbox, interview, and subtask modal classes use the
same phone/short-viewport sheet contract as the earlier FloatingWindow migrations.
FN-8620's New Task, right-dock, and terminal hosts share that contract and must remain
included. Geometry suspension in React must also change the panel, rather than leaving
a fixed desktop-sized dialog on a short or true-phone viewport.
*/
.floating-window.new-task-modal,
.floating-window.right-dock-expand-modal--floating,
.floating-window.terminal-modal--floating,
.floating-window--automation,
.floating-window--agent-list,
.floating-window--agent-import,
.floating-window--agent-generation,
.floating-window--agent-onboarding,
.floating-window--experimental-agent-onboarding,
.floating-window--setup-wizard,
.floating-window--native-shell-onboarding,
.floating-window--docker-node-onboarding,
.floating-window--mailbox,
.floating-window--milestone-slice-interview,
.floating-window--subtask-breakdown,
.floating-window--automation,
.floating-window--activity-log,
.floating-window--scripts,
.floating-window--add-node,
@@ -351,9 +358,18 @@ all floating affordances so persisted desktop geometry cannot imply a draggable
.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--agent-list .floating-window__resize-handle,
.floating-window--agent-import .floating-window__resize-handle,
.floating-window--agent-generation .floating-window__resize-handle,
.floating-window--agent-onboarding .floating-window__resize-handle,
.floating-window--experimental-agent-onboarding .floating-window__resize-handle,
.floating-window--setup-wizard .floating-window__resize-handle,
.floating-window--native-shell-onboarding .floating-window__resize-handle,
.floating-window--docker-node-onboarding .floating-window__resize-handle,
.floating-window--mailbox .floating-window__resize-handle,
.floating-window--milestone-slice-interview .floating-window__resize-handle,
.floating-window--subtask-breakdown .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,
.floating-window--add-node .floating-window__resize-handle,

View File

@@ -523,6 +523,18 @@ export function FloatingWindow({
const panel = panelRef.current;
if (panel?.contains(target)) return;
/*
FNXC:ModalTouchGeometry 2026-07-28-14:30:
FN-8607 modal hosts make the overlay pointer-active to block the application beneath.
The host also carries role="dialog", so it would otherwise match the portal-safe dialog
selector below and suppress its own backdrop dismissal. Only the host itself is outside;
nested portaled dialog surfaces remain safe.
*/
if (target === panel?.parentElement) {
onClose();
return;
}
const targetElement = target instanceof Element ? target : target.parentNode instanceof Element ? target.parentNode : null;
if (targetElement?.closest(FLOATING_WINDOW_OUTSIDE_POINTER_SAFE_SURFACE_SELECTOR)) return;

View File

@@ -407,7 +407,7 @@ export function MilestoneSliceInterviewModal({
if (!isOpen) return null;
return (
<FloatingWindow windowKey="milestone-slice-interview" title={t("missions.planTargetTitle", "Plan {{targetLabel}}: {{targetTitle}}", { targetLabel, targetTitle })} ariaLabel={t("missions.planTargetTitle", "Plan interview")} onClose={handleCancel} hideHeader dragHandleSelector=".planning-modal .modal-header" className="floating-window--milestone-slice-interview" defaultSize={{ width: 760, height: 640 }} minSize={{ width: 440, height: 320 }} persistGeometryKey="floating-window:milestone-slice-interview" suspendGeometryPersistenceOnMobile suspendGeometryPersistenceOnShortViewport closeOnOutsidePointerDown modal testId="milestone-slice-interview-modal">
<FloatingWindow windowKey="milestone-slice-interview" modal testId="milestone-slice-interview-modal" title={t("missions.planTargetTitle", "Plan {{targetLabel}}: {{targetTitle}}", { targetLabel, targetTitle })} ariaLabel={t("missions.planTargetTitle", "Plan interview")} onClose={handleCancel} hideHeader dragHandleSelector=".planning-modal .modal-header" className="floating-window--milestone-slice-interview" defaultSize={{ width: 760, height: 640 }} minSize={{ width: 440, height: 320 }} persistGeometryKey="floating-window:milestone-slice-interview" suspendGeometryPersistenceOnMobile suspendGeometryPersistenceOnShortViewport closeOnOutsidePointerDown>
{/* FNXC:ModalTouchGeometry 2026-07-26-16:22: Interview dismissal still routes through handleCancel so confirmation semantics survive shared tablet geometry. */}
<div className="modal modal-lg planning-modal" style={keyboardStyle}>
<div className="modal-header">

View File

@@ -46,7 +46,7 @@ export function NativeShellOnboardingModal({ open, shellApi, shellState, onCompl
}
return (
<FloatingWindow windowKey="native-shell-onboarding" title={t("onboarding.welcome", "Welcome to Fusion")} ariaLabel={t("onboarding.welcome", "Welcome to Fusion")} onClose={() => {}} 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>
<FloatingWindow windowKey="native-shell-onboarding" modal title={t("onboarding.welcome", "Welcome to Fusion")} ariaLabel={t("onboarding.welcome", "Welcome to Fusion")} onClose={() => {}} 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>
{/* FNXC:ModalTouchGeometry 2026-07-26-16:22: Connection onboarding is blocking, so its shared geometry deliberately has no outside or Escape dismissal. */}
<div className="modal native-shell-onboarding-modal">
<div className="modal-header">

View File

@@ -451,7 +451,7 @@ export function SetupWizardModal({
return (
<FloatingWindow
windowKey="setup-wizard"
windowKey="setup-wizard" modal
title={t("setup.welcomeToFusion", "Welcome to Fusion")}
ariaLabelledBy="wizard-title"
onClose={() => {}}
@@ -463,7 +463,6 @@ export function SetupWizardModal({
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. */}
<div className={modalClassName}>

View File

@@ -605,7 +605,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
if (!isOpen) return null;
return (
<FloatingWindow windowKey="subtask-breakdown" title={t("subtasks.modalTitle", "Subtask Breakdown")} ariaLabel={t("subtasks.modalTitle", "Subtask Breakdown")} onClose={() => 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>
<FloatingWindow windowKey="subtask-breakdown" modal title={t("subtasks.modalTitle", "Subtask Breakdown")} ariaLabel={t("subtasks.modalTitle", "Subtask Breakdown")} onClose={() => 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>
{/* FNXC:ModalTouchGeometry 2026-07-26-16:22: Subtask close stays on handleClose for its confirmation path while FloatingWindow owns all tablet geometry. */}
<div className="modal modal-lg planning-modal" style={keyboardStyle}>
<div className="modal-header">

View File

@@ -1,26 +1,120 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { FloatingWindow } from "../FloatingWindow";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
const viewport = vi.hoisted(() => ({ tablet: false, sheet: false, short: false }));
vi.mock("../../hooks/useViewportMode", async () => {
const actual = await vi.importActual<typeof import("../../hooks/useViewportMode")>("../../hooks/useViewportMode");
return {
...actual,
useViewportMode: () => viewport.tablet ? "tablet" : "desktop",
isTabletTouchViewport: () => viewport.tablet,
isFullScreenSheetViewport: () => viewport.sheet,
isShortViewport: () => viewport.short,
};
});
import { migratedModalFixtures } from "./migratedModalFixtures";
const fixtures = migratedModalFixtures.filter((fixture) => fixture.key && fixture.name.includes("Agent"));
const fixtures = migratedModalFixtures.filter((fixture) => fixture.render && fixture.name.includes("Agent"));
const directions = ["n", "s", "e", "w", "ne", "nw", "se", "sw"] as const;
/* 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) => {
function capture(target: HTMLElement) {
Object.defineProperties(target, {
setPointerCapture: { configurable: true, value: vi.fn() },
releasePointerCapture: { configurable: true, value: vi.fn() },
});
}
afterEach(() => { cleanup(); localStorage.clear(); viewport.tablet = false; viewport.sheet = false; viewport.short = false; document.body.style.userSelect = ""; });
/*
FNXC:ModalTouchGeometry 2026-07-27-01:15:
FN-8607 exercises every production agent modal at the host boundary. The shared primitive owns the
math, while these fixtures prove each modal opts into tablet geometry and preserves its dismissal policy.
*/
describe("agent modal FloatingWindow geometry contract", () => {
it.each(fixtures)("$name supports desktop drag and every resize direction", (fixture) => {
const close = vi.fn();
render(<FloatingWindow modal windowKey={fixture.key!} title={fixture.name} onClose={close} defaultSize={{ width: 320, height: 240 }} defaultPosition={{ x: 80, y: 80 }} persistGeometryKey={fixture.key!}><button>action</button></FloatingWindow>);
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");
render(fixture.render!(close));
const key = fixture.key!.replace("floating-window:", "");
const panel = screen.getByTestId(`floating-window-${key}`);
expect(screen.getByTestId(`floating-window-overlay-${key}`)).toHaveAttribute("role", "dialog");
expect(screen.getAllByLabelText("Resize floating window")).toHaveLength(8);
expect(panel.querySelectorAll("[data-resize-hit-target='true']")).toHaveLength(0);
const drag = panel.querySelector<HTMLElement>(".modal-header, .agent-dialog-header, .setup-wizard-header") ?? panel;
const initialPosition = { left: panel.style.left, top: panel.style.top };
const setCapture = vi.fn();
Object.defineProperty(panel, "setPointerCapture", { configurable: true, value: setCapture });
fireEvent.pointerDown(drag, { pointerType: "mouse", pointerId: 1, clientX: 100, clientY: 100 });
fireEvent.pointerMove(panel, { pointerType: "mouse", pointerId: 1, clientX: 135, clientY: 130 });
fireEvent.pointerUp(panel, { pointerType: "mouse", pointerId: 1, clientX: 135, clientY: 130 });
expect(setCapture).toHaveBeenCalledWith(1);
// The listener commits a clamped geometry record even when jsdom has no layout dimensions.
expect(localStorage.getItem(fixture.key!)).toContain('"position"');
for (const direction of directions) {
const handle = screen.getByTestId(`floating-window-resize-${direction}`);
capture(handle);
const before = { width: panel.style.width, height: panel.style.height };
fireEvent.pointerDown(handle, { pointerType: "mouse", pointerId: 2, clientX: 300, clientY: 300 });
fireEvent.pointerMove(handle, { pointerType: "mouse", pointerId: 2, clientX: 330, clientY: 325 });
fireEvent.pointerUp(handle, { pointerType: "mouse", pointerId: 2, clientX: 330, clientY: 325 });
if (direction.includes("e") || direction.includes("w")) expect(panel.style.width).not.toBe(before.width);
if (direction.includes("n") || direction.includes("s")) expect(panel.style.height).not.toBe(before.height);
}
expect(localStorage.getItem(fixture.key!)).toContain('"size"');
});
it.each(fixtures)("$name enables touch targets, persistence recovery, and sheet suspension", (fixture) => {
viewport.tablet = true;
localStorage.setItem(fixture.key!, "not-json");
const close = vi.fn();
const { unmount } = render(fixture.render!(close));
const key = fixture.key!.replace("floating-window:", "");
const panel = screen.getByTestId(`floating-window-${key}`);
expect(panel.querySelectorAll("[data-resize-hit-target='true']")).toHaveLength(9);
for (const direction of directions) {
const handle = screen.getByTestId(`floating-window-resize-${direction}`);
const before = { width: panel.style.width, height: panel.style.height };
capture(handle);
fireEvent.pointerDown(handle, { pointerType: "touch", pointerId: 1, clientX: 300, clientY: 300 });
fireEvent.pointerMove(handle, { pointerType: "touch", pointerId: 99, clientX: 600, clientY: 600 });
fireEvent.pointerMove(handle, { pointerType: "touch", pointerId: 1, clientX: 330, clientY: 325 });
fireEvent.pointerUp(handle, { pointerType: "touch", pointerId: 1, clientX: 330, clientY: 325 });
if (direction.includes("e") || direction.includes("w")) expect(panel.style.width).not.toBe(before.width);
if (direction.includes("n") || direction.includes("s")) expect(panel.style.height).not.toBe(before.height);
}
expect(() => JSON.parse(localStorage.getItem(fixture.key!)!)).not.toThrow();
const drag = panel.querySelector<HTMLElement>(".modal-header, .agent-dialog-header, .setup-wizard-header") ?? panel;
capture(panel);
fireEvent.pointerDown(drag, { pointerType: "touch", pointerId: 7, clientX: 100, clientY: 100 });
expect(document.body.style.userSelect).toBe("none");
fireEvent.pointerCancel(panel, { pointerType: "touch", pointerId: 7 });
expect(document.body.style.userSelect).toBe("");
unmount();
// A populated off-screen record must restore through the shared clamp, not strand the hosted dialog.
viewport.tablet = false;
localStorage.setItem(fixture.key!, JSON.stringify({ size: { width: 100_000, height: 100_000 }, position: { x: 100_000, y: 100_000 } }));
render(fixture.render!(close));
const restored = screen.getByTestId(`floating-window-${key}`);
expect(Number.parseInt(restored.style.width, 10)).toBeLessThanOrEqual(window.innerWidth - 32);
expect(Number.parseInt(restored.style.height, 10)).toBeLessThanOrEqual(window.innerHeight - 32);
cleanup();
// 767px/phone is represented by the shared sheet discriminator; it must not expose or write geometry.
viewport.sheet = true; localStorage.clear();
render(fixture.render!(close));
expect(screen.queryAllByLabelText("Resize floating window")).toHaveLength(0);
expect(localStorage.getItem(fixture.key!)).toBeNull();
cleanup(); viewport.sheet = false; viewport.short = true;
render(fixture.render!(close));
expect(screen.queryAllByLabelText("Resize floating window")).toHaveLength(0);
expect(localStorage.getItem(fixture.key!)).toBeNull();
});
it.each(fixtures)("$name keeps its outside dismissal decision", (fixture) => {
const close = vi.fn(); render(fixture.render!(close));
fireEvent.pointerDown(document.body, { pointerType: "mouse" });
expect(close).toHaveBeenCalledTimes(fixture.outside ? 1 : 0);
});
});

View File

@@ -1,20 +1,120 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { FloatingWindow } from "../FloatingWindow";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
const viewport = vi.hoisted(() => ({ tablet: false, sheet: false, short: false }));
vi.mock("../../hooks/useViewportMode", async () => {
const actual = await vi.importActual<typeof import("../../hooks/useViewportMode")>("../../hooks/useViewportMode");
return {
...actual,
useViewportMode: () => viewport.tablet ? "tablet" : "desktop",
isTabletTouchViewport: () => viewport.tablet,
isFullScreenSheetViewport: () => viewport.sheet,
isShortViewport: () => viewport.short,
};
});
import { migratedModalFixtures } from "./migratedModalFixtures";
const fixtures = migratedModalFixtures.filter((fixture) => fixture.key && /Setup|Native|Docker/.test(fixture.name));
const fixtures = migratedModalFixtures.filter((fixture) => fixture.render && /Setup|Native|Docker/.test(fixture.name));
const directions = ["n", "s", "e", "w", "ne", "nw", "se", "sw"] as const;
describe("onboarding modal FloatingWindow behavior", () => {
it.each(fixtures)("$name keeps a blocking focus boundary and geometry handles", (fixture) => {
render(<FloatingWindow modal windowKey={fixture.key!} title={fixture.name} onClose={vi.fn()} defaultSize={{ width: 320, height: 240 }} defaultPosition={{ x: 80, y: 80 }} persistGeometryKey={fixture.key!}><button>first</button><button>last</button></FloatingWindow>);
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");
function capture(target: HTMLElement) {
Object.defineProperties(target, {
setPointerCapture: { configurable: true, value: vi.fn() },
releasePointerCapture: { configurable: true, value: vi.fn() },
});
}
afterEach(() => { cleanup(); localStorage.clear(); viewport.tablet = false; viewport.sheet = false; viewport.short = false; document.body.style.userSelect = ""; });
/*
FNXC:ModalTouchGeometry 2026-07-27-01:15:
FN-8607 exercises every production onboarding modal at the host boundary. The shared primitive owns the
math, while these fixtures prove each modal opts into tablet geometry and preserves its dismissal policy.
*/
describe("onboarding modal FloatingWindow geometry contract", () => {
it.each(fixtures)("$name supports desktop drag and every resize direction", (fixture) => {
const close = vi.fn();
render(fixture.render!(close));
const key = fixture.key!.replace("floating-window:", "");
const panel = screen.getByTestId(`floating-window-${key}`);
expect(screen.getByTestId(`floating-window-overlay-${key}`)).toHaveAttribute("role", "dialog");
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);
expect(panel.querySelectorAll("[data-resize-hit-target='true']")).toHaveLength(0);
const drag = panel.querySelector<HTMLElement>(".modal-header, .agent-dialog-header, .setup-wizard-header") ?? panel;
const initialPosition = { left: panel.style.left, top: panel.style.top };
const setCapture = vi.fn();
Object.defineProperty(panel, "setPointerCapture", { configurable: true, value: setCapture });
fireEvent.pointerDown(drag, { pointerType: "mouse", pointerId: 1, clientX: 100, clientY: 100 });
fireEvent.pointerMove(panel, { pointerType: "mouse", pointerId: 1, clientX: 135, clientY: 130 });
fireEvent.pointerUp(panel, { pointerType: "mouse", pointerId: 1, clientX: 135, clientY: 130 });
expect(setCapture).toHaveBeenCalledWith(1);
// The listener commits a clamped geometry record even when jsdom has no layout dimensions.
expect(localStorage.getItem(fixture.key!)).toContain('"position"');
for (const direction of directions) {
const handle = screen.getByTestId(`floating-window-resize-${direction}`);
capture(handle);
const before = { width: panel.style.width, height: panel.style.height };
fireEvent.pointerDown(handle, { pointerType: "mouse", pointerId: 2, clientX: 300, clientY: 300 });
fireEvent.pointerMove(handle, { pointerType: "mouse", pointerId: 2, clientX: 330, clientY: 325 });
fireEvent.pointerUp(handle, { pointerType: "mouse", pointerId: 2, clientX: 330, clientY: 325 });
if (direction.includes("e") || direction.includes("w")) expect(panel.style.width).not.toBe(before.width);
if (direction.includes("n") || direction.includes("s")) expect(panel.style.height).not.toBe(before.height);
}
expect(localStorage.getItem(fixture.key!)).toContain('"size"');
});
it.each(fixtures)("$name enables touch targets, persistence recovery, and sheet suspension", (fixture) => {
viewport.tablet = true;
localStorage.setItem(fixture.key!, "not-json");
const close = vi.fn();
const { unmount } = render(fixture.render!(close));
const key = fixture.key!.replace("floating-window:", "");
const panel = screen.getByTestId(`floating-window-${key}`);
expect(panel.querySelectorAll("[data-resize-hit-target='true']")).toHaveLength(9);
for (const direction of directions) {
const handle = screen.getByTestId(`floating-window-resize-${direction}`);
const before = { width: panel.style.width, height: panel.style.height };
capture(handle);
fireEvent.pointerDown(handle, { pointerType: "touch", pointerId: 1, clientX: 300, clientY: 300 });
fireEvent.pointerMove(handle, { pointerType: "touch", pointerId: 99, clientX: 600, clientY: 600 });
fireEvent.pointerMove(handle, { pointerType: "touch", pointerId: 1, clientX: 330, clientY: 325 });
fireEvent.pointerUp(handle, { pointerType: "touch", pointerId: 1, clientX: 330, clientY: 325 });
if (direction.includes("e") || direction.includes("w")) expect(panel.style.width).not.toBe(before.width);
if (direction.includes("n") || direction.includes("s")) expect(panel.style.height).not.toBe(before.height);
}
expect(() => JSON.parse(localStorage.getItem(fixture.key!)!)).not.toThrow();
const drag = panel.querySelector<HTMLElement>(".modal-header, .agent-dialog-header, .setup-wizard-header") ?? panel;
capture(panel);
fireEvent.pointerDown(drag, { pointerType: "touch", pointerId: 7, clientX: 100, clientY: 100 });
expect(document.body.style.userSelect).toBe("none");
fireEvent.pointerCancel(panel, { pointerType: "touch", pointerId: 7 });
expect(document.body.style.userSelect).toBe("");
unmount();
// A populated off-screen record must restore through the shared clamp, not strand the hosted dialog.
viewport.tablet = false;
localStorage.setItem(fixture.key!, JSON.stringify({ size: { width: 100_000, height: 100_000 }, position: { x: 100_000, y: 100_000 } }));
render(fixture.render!(close));
const restored = screen.getByTestId(`floating-window-${key}`);
expect(Number.parseInt(restored.style.width, 10)).toBeLessThanOrEqual(window.innerWidth - 32);
expect(Number.parseInt(restored.style.height, 10)).toBeLessThanOrEqual(window.innerHeight - 32);
cleanup();
// 767px/phone is represented by the shared sheet discriminator; it must not expose or write geometry.
viewport.sheet = true; localStorage.clear();
render(fixture.render!(close));
expect(screen.queryAllByLabelText("Resize floating window")).toHaveLength(0);
expect(localStorage.getItem(fixture.key!)).toBeNull();
cleanup(); viewport.sheet = false; viewport.short = true;
render(fixture.render!(close));
expect(screen.queryAllByLabelText("Resize floating window")).toHaveLength(0);
expect(localStorage.getItem(fixture.key!)).toBeNull();
});
it.each(fixtures)("$name keeps its outside dismissal decision", (fixture) => {
const close = vi.fn(); render(fixture.render!(close));
fireEvent.pointerDown(document.body, { pointerType: "mouse" });
expect(close).toHaveBeenCalledTimes(fixture.outside ? 1 : 0);
});
});

View File

@@ -1,18 +1,122 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { FloatingWindow } from "../FloatingWindow";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
const viewport = vi.hoisted(() => ({ tablet: false, sheet: false, short: false }));
vi.mock("../../hooks/useViewportMode", async () => {
const actual = await vi.importActual<typeof import("../../hooks/useViewportMode")>("../../hooks/useViewportMode");
return {
...actual,
useViewportMode: () => viewport.tablet ? "tablet" : "desktop",
isTabletTouchViewport: () => viewport.tablet,
isFullScreenSheetViewport: () => viewport.sheet,
isShortViewport: () => viewport.short,
};
});
import { migratedModalFixtures } from "./migratedModalFixtures";
const fixtures = migratedModalFixtures.filter((fixture) => fixture.key && /Mailbox|Milestone|Subtask/.test(fixture.name));
const fixtures = migratedModalFixtures.filter((fixture) => fixture.render && /Mailbox|Milestone|Subtask/.test(fixture.name));
const directions = ["n", "s", "e", "w", "ne", "nw", "se", "sw"] as const;
describe("utility modal FloatingWindow behavior", () => {
it.each(fixtures)("$name blocks page clicks while preserving outside-dismiss choice", (fixture) => {
function capture(target: HTMLElement) {
Object.defineProperties(target, {
setPointerCapture: { configurable: true, value: vi.fn() },
releasePointerCapture: { configurable: true, value: vi.fn() },
});
}
afterEach(() => { cleanup(); localStorage.clear(); viewport.tablet = false; viewport.sheet = false; viewport.short = false; document.body.style.userSelect = ""; });
/*
FNXC:ModalTouchGeometry 2026-07-27-01:15:
FN-8607 exercises every production utility modal at the host boundary. The shared primitive owns the
math, while these fixtures prove each modal opts into tablet geometry and preserves its dismissal policy.
*/
describe("utility modal FloatingWindow geometry contract", () => {
it.each(fixtures)("$name supports desktop drag and every resize direction", (fixture) => {
const close = vi.fn();
render(<><button data-testid="page-control">page</button><FloatingWindow modal windowKey={fixture.key!} title={fixture.name} onClose={close} defaultSize={{ width: 320, height: 240 }} defaultPosition={{ x: 80, y: 80 }} persistGeometryKey={fixture.key!} closeOnOutsidePointerDown={fixture.outside}><button>inside</button></FloatingWindow></>);
const host = screen.getByTestId(`floating-window-overlay-${fixture.key}`);
expect(host).toHaveAttribute("aria-modal", "true");
render(fixture.render!(close));
const key = fixture.key!.replace("floating-window:", "");
const panel = screen.getByTestId(`floating-window-${key}`);
expect(screen.getByRole("dialog")).toHaveAttribute("aria-modal", "true");
if (fixture.name === "MailboxModal") expect(screen.getByTestId("mailbox-modal-overlay")).toHaveAttribute("role", "dialog");
if (fixture.name === "MilestoneSliceInterviewModal") expect(screen.getByTestId("milestone-slice-interview-modal")).toHaveAttribute("role", "dialog");
expect(screen.getAllByLabelText("Resize floating window")).toHaveLength(8);
expect(panel.querySelectorAll("[data-resize-hit-target='true']")).toHaveLength(0);
const drag = panel.querySelector<HTMLElement>(".modal-header, .agent-dialog-header, .setup-wizard-header") ?? panel;
const initialPosition = { left: panel.style.left, top: panel.style.top };
const setCapture = vi.fn();
Object.defineProperty(panel, "setPointerCapture", { configurable: true, value: setCapture });
fireEvent.pointerDown(drag, { pointerType: "mouse", pointerId: 1, clientX: 100, clientY: 100 });
fireEvent.pointerMove(panel, { pointerType: "mouse", pointerId: 1, clientX: 135, clientY: 130 });
fireEvent.pointerUp(panel, { pointerType: "mouse", pointerId: 1, clientX: 135, clientY: 130 });
expect(setCapture).toHaveBeenCalledWith(1);
// The listener commits a clamped geometry record even when jsdom has no layout dimensions.
expect(localStorage.getItem(fixture.key!)).toContain('"position"');
for (const direction of directions) {
const handle = screen.getByTestId(`floating-window-resize-${direction}`);
capture(handle);
const before = { width: panel.style.width, height: panel.style.height };
fireEvent.pointerDown(handle, { pointerType: "mouse", pointerId: 2, clientX: 300, clientY: 300 });
fireEvent.pointerMove(handle, { pointerType: "mouse", pointerId: 2, clientX: 330, clientY: 325 });
fireEvent.pointerUp(handle, { pointerType: "mouse", pointerId: 2, clientX: 330, clientY: 325 });
if (direction.includes("e") || direction.includes("w")) expect(panel.style.width).not.toBe(before.width);
if (direction.includes("n") || direction.includes("s")) expect(panel.style.height).not.toBe(before.height);
}
expect(localStorage.getItem(fixture.key!)).toContain('"size"');
});
it.each(fixtures)("$name enables touch targets, persistence recovery, and sheet suspension", (fixture) => {
viewport.tablet = true;
localStorage.setItem(fixture.key!, "not-json");
const close = vi.fn();
const { unmount } = render(fixture.render!(close));
const key = fixture.key!.replace("floating-window:", "");
const panel = screen.getByTestId(`floating-window-${key}`);
expect(panel.querySelectorAll("[data-resize-hit-target='true']")).toHaveLength(9);
for (const direction of directions) {
const handle = screen.getByTestId(`floating-window-resize-${direction}`);
const before = { width: panel.style.width, height: panel.style.height };
capture(handle);
fireEvent.pointerDown(handle, { pointerType: "touch", pointerId: 1, clientX: 300, clientY: 300 });
fireEvent.pointerMove(handle, { pointerType: "touch", pointerId: 99, clientX: 600, clientY: 600 });
fireEvent.pointerMove(handle, { pointerType: "touch", pointerId: 1, clientX: 330, clientY: 325 });
fireEvent.pointerUp(handle, { pointerType: "touch", pointerId: 1, clientX: 330, clientY: 325 });
if (direction.includes("e") || direction.includes("w")) expect(panel.style.width).not.toBe(before.width);
if (direction.includes("n") || direction.includes("s")) expect(panel.style.height).not.toBe(before.height);
}
expect(() => JSON.parse(localStorage.getItem(fixture.key!)!)).not.toThrow();
const drag = panel.querySelector<HTMLElement>(".modal-header, .agent-dialog-header, .setup-wizard-header") ?? panel;
capture(panel);
fireEvent.pointerDown(drag, { pointerType: "touch", pointerId: 7, clientX: 100, clientY: 100 });
expect(document.body.style.userSelect).toBe("none");
fireEvent.pointerCancel(panel, { pointerType: "touch", pointerId: 7 });
expect(document.body.style.userSelect).toBe("");
unmount();
// A populated off-screen record must restore through the shared clamp, not strand the hosted dialog.
viewport.tablet = false;
localStorage.setItem(fixture.key!, JSON.stringify({ size: { width: 100_000, height: 100_000 }, position: { x: 100_000, y: 100_000 } }));
render(fixture.render!(close));
const restored = screen.getByTestId(`floating-window-${key}`);
expect(Number.parseInt(restored.style.width, 10)).toBeLessThanOrEqual(window.innerWidth - 32);
expect(Number.parseInt(restored.style.height, 10)).toBeLessThanOrEqual(window.innerHeight - 32);
cleanup();
// 767px/phone is represented by the shared sheet discriminator; it must not expose or write geometry.
viewport.sheet = true; localStorage.clear();
render(fixture.render!(close));
expect(screen.queryAllByLabelText("Resize floating window")).toHaveLength(0);
expect(localStorage.getItem(fixture.key!)).toBeNull();
cleanup(); viewport.sheet = false; viewport.short = true;
render(fixture.render!(close));
expect(screen.queryAllByLabelText("Resize floating window")).toHaveLength(0);
expect(localStorage.getItem(fixture.key!)).toBeNull();
});
it.each(fixtures)("$name keeps its outside dismissal decision", (fixture) => {
const close = vi.fn(); render(fixture.render!(close));
fireEvent.pointerDown(document.body, { pointerType: "mouse" });
if (fixture.outside) expect(close).toHaveBeenCalledOnce(); else expect(close).not.toHaveBeenCalled();
expect(close).toHaveBeenCalledTimes(fixture.outside ? 1 : 0);
});
});

View File

@@ -1,25 +1,52 @@
import { createElement, type ReactElement } from "react";
import { AgentErrorDetailsModal } from "../AgentErrorDetailsModal";
import { AgentGenerationModal } from "../AgentGenerationModal";
import { AgentImportModal } from "../AgentImportModal";
import { AgentListModal } from "../AgentListModal";
import { AgentOnboardingModal } from "../AgentOnboardingModal";
import { DockerNodeOnboardingModal } from "../DockerNodeOnboardingModal";
import { ExperimentalAgentOnboardingModal } from "../ExperimentalAgentOnboardingModal";
import { MailboxModal } from "../MailboxModal";
import { MilestoneSliceInterviewModal } from "../MilestoneSliceInterviewModal";
import { NativeShellOnboardingModal } from "../NativeShellOnboardingModal";
import { SetupWizardModal } from "../SetupWizardModal";
import { SubtaskBreakdownModal } from "../SubtaskBreakdownModal";
export type MigratedModalFixture = {
name: string;
file: string;
key: string | null;
outside: boolean;
/** Renders the production modal, never a synthetic FloatingWindow stand-in. */
render?: (onClose: () => void) => ReactElement;
optOut?: string;
};
const noop = () => {};
const toast = () => {};
/*
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.
FNXC:ModalTouchGeometry 2026-07-26-20:20:
FN-8607 coverage must mount every production host rather than a generic FloatingWindow. This
keeps header selectors, sheet classes, and each modal's close contract under the same test matrix.
The inventory's short-lived decision dialogs remain explicit opt-outs below.
*/
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.
export const migratedModalFixtures: readonly MigratedModalFixture[] = [
{ 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 },
{ name: "AgentListModal", file: "AgentListModal.tsx", key: "floating-window:agent-list", outside: true, render: (onClose) => createElement(AgentListModal, { isOpen: true, onClose, addToast: toast }) },
{ name: "AgentImportModal", file: "AgentImportModal.tsx", key: "floating-window:agent-import", outside: true, render: (onClose) => createElement(AgentImportModal, { isOpen: true, onClose, onImported: noop }) },
{ name: "AgentGenerationModal", file: "AgentGenerationModal.tsx", key: "floating-window:agent-generation", outside: true, render: (onClose) => createElement(AgentGenerationModal, { isOpen: true, onClose, onGenerated: noop }) },
{ name: "AgentOnboardingModal", file: "AgentOnboardingModal.tsx", key: "floating-window:agent-onboarding", outside: false, render: (onClose) => createElement(AgentOnboardingModal, { isOpen: true, onClose, onCreated: noop, addToast: toast, existingAgents: [] }) },
{ name: "ExperimentalAgentOnboardingModal", file: "ExperimentalAgentOnboardingModal.tsx", key: "floating-window:experimental-agent-onboarding", outside: false, render: (onClose) => createElement(ExperimentalAgentOnboardingModal, { isOpen: true, onClose, onUseDraft: noop, existingAgents: [] }) },
{ name: "SetupWizardModal", file: "SetupWizardModal.tsx", key: "floating-window:setup-wizard", outside: false, render: (onClose) => createElement(SetupWizardModal, { onProjectRegistered: noop, onClose }) },
{ name: "NativeShellOnboardingModal", file: "NativeShellOnboardingModal.tsx", key: "floating-window:native-shell-onboarding", outside: false, render: (onClose) => createElement(NativeShellOnboardingModal, { open: true, onComplete: onClose, shellApi: {} as never, shellState: {} as never }) },
{ name: "DockerNodeOnboardingModal", file: "DockerNodeOnboardingModal.tsx", key: "floating-window:docker-node-onboarding", outside: true, render: (onClose) => createElement(DockerNodeOnboardingModal, { isOpen: true, onClose, onSubmit: async () => {}, addToast: toast }) },
{ name: "MailboxModal", file: "MailboxModal.tsx", key: "floating-window:mailbox", outside: true, render: (onClose) => createElement(MailboxModal, { isOpen: true, onClose, addToast: toast, onOpenTask: noop, onOpenPlanningSession: noop, onOpenNativeStructure: noop, nativeStructureCandidates: [] }) },
{ name: "MilestoneSliceInterviewModal", file: "MilestoneSliceInterviewModal.tsx", key: "floating-window:milestone-slice-interview", outside: true, render: (onClose) => createElement(MilestoneSliceInterviewModal, { isOpen: true, onClose, onApplied: noop, targetType: "milestone", targetId: "m-1", targetTitle: "Mission", missionContext: "Test context" }) },
{ name: "SubtaskBreakdownModal", file: "SubtaskBreakdownModal.tsx", key: "floating-window:subtask-breakdown", outside: true, render: (onClose) => createElement(SubtaskBreakdownModal, { isOpen: true, onClose, initialDescription: "Test task", onTasksCreated: noop, parentTaskId: "task-1" }) },
] as const;

View File

@@ -21,10 +21,20 @@ describe("FN-8607 migrated modal FloatingWindow contract", () => {
expect(fixture.optOut).toMatch(/.+/);
});
it("uses modal isolation instead of the utility host click-through mode", () => {
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");
it("records every hosted production modal in the fixture table", () => {
expect(migratedModalFixtures.filter((fixture) => fixture.key)).toHaveLength(11);
});
it("makes every FN-8607 host a full-screen sheet on phone and short viewports", () => {
const css = readAppFile("components/FloatingWindow.css");
const sheetBlock = css.match(/@media \(max-width: 767\.98px\), \(max-height: 480px\) \{[\s\S]*?\.floating-window--image-preview \{/);
expect(sheetBlock?.[0]).toContain("width: 100vw !important;");
expect(sheetBlock?.[0]).toContain("height: 100dvh !important;");
for (const fixture of migratedModalFixtures.filter((candidate) => candidate.key)) {
const className = fixture.key!.replace("floating-window:", "floating-window--");
expect(sheetBlock?.[0]).toContain(className);
expect(css).toContain(`${className} .floating-window__resize-handle`);
}
});
});