From 89b4621a9315cbed32312d9aff5f09fa213dca7b Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 26 Jul 2026 18:57:02 -0700 Subject: [PATCH] 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) --- docs/dashboard-guide.md | 6 +- .../app/components/AgentGenerationModal.tsx | 8 +- .../app/components/AgentImportModal.tsx | 4 +- .../app/components/AgentListModal.tsx | 4 +- .../app/components/AgentOnboardingModal.tsx | 4 +- .../components/DockerNodeOnboardingModal.tsx | 4 +- .../ExperimentalAgentOnboardingModal.tsx | 4 +- .../app/components/FloatingWindow.css | 36 +++-- .../app/components/FloatingWindow.tsx | 12 ++ .../MilestoneSliceInterviewModal.tsx | 2 +- .../components/NativeShellOnboardingModal.tsx | 2 +- .../app/components/SetupWizardModal.tsx | 3 +- .../app/components/SubtaskBreakdownModal.tsx | 2 +- .../AgentModals.floatingWindow.test.tsx | 134 +++++++++++++++--- .../OnboardingModals.floatingWindow.test.tsx | 128 +++++++++++++++-- .../UtilityModals.floatingWindow.test.tsx | 124 ++++++++++++++-- .../__tests__/migratedModalFixtures.tsx | 59 +++++--- .../modalFloatingWindowContract.test.tsx | 20 ++- 18 files changed, 458 insertions(+), 98 deletions(-) diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 4fca3d15f9..dff9f89715 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -108,7 +108,7 @@ Core/workflow FloatingWindow modals use `persistGeometryKey="floating-window: + {/* FNXC:ModalTouchGeometry 2026-07-26-16:07: Generation can dwell while AI responds; persist shared geometry and preserve overlay dismissal. */} -
+
{/* Header */}
diff --git a/packages/dashboard/app/components/AgentImportModal.tsx b/packages/dashboard/app/components/AgentImportModal.tsx index e8a2b60877..82d76ef4c3 100644 --- a/packages/dashboard/app/components/AgentImportModal.tsx +++ b/packages/dashboard/app/components/AgentImportModal.tsx @@ -471,9 +471,9 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi if (!isOpen) return null; return ( - + {/* FNXC:ModalTouchGeometry 2026-07-26-16:07: Import mapping is reflowable, so FloatingWindow owns tablet touch geometry and retains prior outside dismissal. */} -
+
{/* Header */}
{t("agents.importAgents", "Import Agents")} diff --git a/packages/dashboard/app/components/AgentListModal.tsx b/packages/dashboard/app/components/AgentListModal.tsx index 0134f69932..b9d75611fa 100644 --- a/packages/dashboard/app/components/AgentListModal.tsx +++ b/packages/dashboard/app/components/AgentListModal.tsx @@ -287,9 +287,9 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi if (!isOpen) return null; return ( - + {/* 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. */} -
+

diff --git a/packages/dashboard/app/components/AgentOnboardingModal.tsx b/packages/dashboard/app/components/AgentOnboardingModal.tsx index c116bad367..8e1bc9f880 100644 --- a/packages/dashboard/app/components/AgentOnboardingModal.tsx +++ b/packages/dashboard/app/components/AgentOnboardingModal.tsx @@ -192,9 +192,9 @@ export function AgentOnboardingModal({ isOpen, onClose, onCreated, addToast, pro }; return ( - void handleClose()} hideHeader dragHandleSelector=".agent-onboarding-modal .modal-header" className="floating-window--agent-onboarding" defaultSize={{ width: 720, height: 620 }} minSize={{ width: 420, height: 320 }} persistGeometryKey="floating-window:agent-onboarding" suspendGeometryPersistenceOnMobile suspendGeometryPersistenceOnShortViewport modal> + 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. */} -
+

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

diff --git a/packages/dashboard/app/components/DockerNodeOnboardingModal.tsx b/packages/dashboard/app/components/DockerNodeOnboardingModal.tsx index 522fe25fa2..68e729a91f 100644 --- a/packages/dashboard/app/components/DockerNodeOnboardingModal.tsx +++ b/packages/dashboard/app/components/DockerNodeOnboardingModal.tsx @@ -212,9 +212,9 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast: if (!isOpen) return null; return ( - { 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> + { 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. */} -
+

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

diff --git a/packages/dashboard/app/components/FloatingWindow.css b/packages/dashboard/app/components/FloatingWindow.css index d5e085c0b0..43e937dfe1 100644 --- a/packages/dashboard/app/components/FloatingWindow.css +++ b/packages/dashboard/app/components/FloatingWindow.css @@ -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, diff --git a/packages/dashboard/app/components/FloatingWindow.tsx b/packages/dashboard/app/components/FloatingWindow.tsx index e7dce975a3..b449bed36e 100644 --- a/packages/dashboard/app/components/FloatingWindow.tsx +++ b/packages/dashboard/app/components/FloatingWindow.tsx @@ -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; diff --git a/packages/dashboard/app/components/MilestoneSliceInterviewModal.tsx b/packages/dashboard/app/components/MilestoneSliceInterviewModal.tsx index 18879ec6cc..2150bfa551 100644 --- a/packages/dashboard/app/components/MilestoneSliceInterviewModal.tsx +++ b/packages/dashboard/app/components/MilestoneSliceInterviewModal.tsx @@ -407,7 +407,7 @@ export function MilestoneSliceInterviewModal({ if (!isOpen) return null; return ( - + {/* FNXC:ModalTouchGeometry 2026-07-26-16:22: Interview dismissal still routes through handleCancel so confirmation semantics survive shared tablet geometry. */}
diff --git a/packages/dashboard/app/components/NativeShellOnboardingModal.tsx b/packages/dashboard/app/components/NativeShellOnboardingModal.tsx index ce4b984b04..61c26d721b 100644 --- a/packages/dashboard/app/components/NativeShellOnboardingModal.tsx +++ b/packages/dashboard/app/components/NativeShellOnboardingModal.tsx @@ -46,7 +46,7 @@ export function NativeShellOnboardingModal({ open, shellApi, shellState, onCompl } return ( - {}} hideHeader dragHandleSelector=".native-shell-onboarding-modal .modal-header" className="floating-window--native-shell-onboarding" defaultSize={{ width: 640, height: 560 }} minSize={{ width: 400, height: 320 }} persistGeometryKey="floating-window:native-shell-onboarding" suspendGeometryPersistenceOnMobile suspendGeometryPersistenceOnShortViewport modal> + {}} 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. */}
diff --git a/packages/dashboard/app/components/SetupWizardModal.tsx b/packages/dashboard/app/components/SetupWizardModal.tsx index 84681af184..808adc827e 100644 --- a/packages/dashboard/app/components/SetupWizardModal.tsx +++ b/packages/dashboard/app/components/SetupWizardModal.tsx @@ -451,7 +451,7 @@ export function SetupWizardModal({ return ( {}} @@ -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. */}
diff --git a/packages/dashboard/app/components/SubtaskBreakdownModal.tsx b/packages/dashboard/app/components/SubtaskBreakdownModal.tsx index 7ad539a04e..50ff4fb7ea 100644 --- a/packages/dashboard/app/components/SubtaskBreakdownModal.tsx +++ b/packages/dashboard/app/components/SubtaskBreakdownModal.tsx @@ -605,7 +605,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT if (!isOpen) return null; return ( - 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> + 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. */}
diff --git a/packages/dashboard/app/components/__tests__/AgentModals.floatingWindow.test.tsx b/packages/dashboard/app/components/__tests__/AgentModals.floatingWindow.test.tsx index a6e5f32951..bb5c2fe024 100644 --- a/packages/dashboard/app/components/__tests__/AgentModals.floatingWindow.test.tsx +++ b/packages/dashboard/app/components/__tests__/AgentModals.floatingWindow.test.tsx @@ -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("../../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(); - 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(".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(".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); }); }); diff --git a/packages/dashboard/app/components/__tests__/OnboardingModals.floatingWindow.test.tsx b/packages/dashboard/app/components/__tests__/OnboardingModals.floatingWindow.test.tsx index 6113d8891b..7529c34459 100644 --- a/packages/dashboard/app/components/__tests__/OnboardingModals.floatingWindow.test.tsx +++ b/packages/dashboard/app/components/__tests__/OnboardingModals.floatingWindow.test.tsx @@ -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("../../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(); - 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(".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(".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); }); }); diff --git a/packages/dashboard/app/components/__tests__/UtilityModals.floatingWindow.test.tsx b/packages/dashboard/app/components/__tests__/UtilityModals.floatingWindow.test.tsx index c68a6a4901..4515be0e95 100644 --- a/packages/dashboard/app/components/__tests__/UtilityModals.floatingWindow.test.tsx +++ b/packages/dashboard/app/components/__tests__/UtilityModals.floatingWindow.test.tsx @@ -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("../../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(<>); - 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(".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(".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); }); }); diff --git a/packages/dashboard/app/components/__tests__/migratedModalFixtures.tsx b/packages/dashboard/app/components/__tests__/migratedModalFixtures.tsx index 88cbf373af..2b19bb00ce 100644 --- a/packages/dashboard/app/components/__tests__/migratedModalFixtures.tsx +++ b/packages/dashboard/app/components/__tests__/migratedModalFixtures.tsx @@ -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; diff --git a/packages/dashboard/app/components/__tests__/modalFloatingWindowContract.test.tsx b/packages/dashboard/app/components/__tests__/modalFloatingWindowContract.test.tsx index e2653ef4a2..8414276004 100644 --- a/packages/dashboard/app/components/__tests__/modalFloatingWindowContract.test.tsx +++ b/packages/dashboard/app/components/__tests__/modalFloatingWindowContract.test.tsx @@ -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`); + } + }); + });