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:
@@ -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 |
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user