diff --git a/.changeset/fn-8605-floating-window-touch.md b/.changeset/fn-8605-floating-window-touch.md new file mode 100644 index 0000000000..f8fb9f3e4b --- /dev/null +++ b/.changeset/fn-8605-floating-window-touch.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Make floating dashboard windows touch-movable and resizable on tablets. +category: fix +dev: Aligns phone sheets below 768px and reuses the shared tablet touch-target contract. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 8e67f720e5..7b8e4e7a82 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -91,6 +91,11 @@ Movable dashboard pop-outs remember their last desktop location and size, while Task Detail and New Task remain resizable on known touch tablets, including a 768px-wide tablet viewport. Task Detail exposes its accessible bottom-right resize grip; New Task keeps its draggable header and edge/corner resize controls. On that tablet-touch surface, the painted control remains compact but its explicit resize hit target is at least 44px, sits outside the panel content, and owns touch gestures with pointer capture. Their geometry stays within the viewport and is restored from browser storage on later tablet or desktop opens. True phones, narrow folded panes, and desktop coarse-pointer devices do not receive the enlarged target: phones remain full-screen sheets and desktop preserves cursor-sized resize chrome. + +### Shared floating-window touch contract + +Use `FloatingWindow` for a moveable and resizable dashboard surface rather than adding per-modal pointer code. On known tablet touch viewports it uses `isTabletTouchViewport`, applies `data-resize-hit-target="true"` to the drag handle and all eight edge/corner handles, and expands only their hit areas to the shared 44px target without thickening painted borders or covering content/footer controls. Never gate these controls on bare `(pointer: coarse)`: desktop hybrids keep desktop geometry. Phone full-screen sheets are strictly **below 768px** (`max-width: 767.98px`); a 768px viewport is tablet-class, so JS geometry and CSS must preserve active targets there. + ## Mobile/PWA app icons The installed mobile/PWA home-screen icons are generated from `packages/dashboard/app/public/logo.svg` by the desktop icon generator. When the Fusion brand mark changes, run `pnpm --filter @fusion/desktop generate:icons` so `packages/dashboard/app/public/icons/icon-192.png` and `packages/dashboard/app/public/icons/icon-512.png` stay aligned with the canonical logo. Also bump `CACHE_NAME` in `packages/dashboard/app/public/sw.js` whenever those icon assets change so installed PWAs refresh the cached launcher images. diff --git a/docs/testing.md b/docs/testing.md index be193e35f6..3fae152336 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -526,4 +526,4 @@ Symptom-based acceptance is mandatory for bug fixes: reproduce the original fail ### Chromium touch hit-testing -Touch-resize regressions use the dashboard Vite fixture and Chromium CDP `Input.dispatchTouchEvent` start/move/end events. This is required where `elementFromPoint` and real touch hit testing matter; jsdom pointer dispatch does not provide layout hit testing. +Touch-resize regressions use the dashboard Vite fixture and Chromium CDP `Input.dispatchTouchEvent` start/move/end events. The shared task-modal/FloatingWindow lane in `packages/dashboard/src/__tests__/task-modal-touch-resize-browser.test.ts` covers 768px and wider tablets, a 767px phone sheet, and true phones on an ephemeral Vite port. This is required where `elementFromPoint` and real touch hit testing matter; jsdom pointer dispatch does not provide layout hit testing. diff --git a/packages/dashboard/app/components/FloatingWindow.css b/packages/dashboard/app/components/FloatingWindow.css index 55e1d2086c..a712364904 100644 --- a/packages/dashboard/app/components/FloatingWindow.css +++ b/packages/dashboard/app/components/FloatingWindow.css @@ -107,40 +107,114 @@ Headerless task pop-outs still need a visible drag affordance. The embedded task } /* -FNXC:FloatingWindow 2026-07-21-00:00: -Tablet task-detail popups prioritize symmetric content insets over desktop resize affordances. Their body removes the desktop scrollbar-clearance gutter only while all resize handles are suppressed; desktop retains the clearance and resize-safe handles, and the delegated task header remains draggable. +FNXC:ModalTouchGeometry 2026-07-26-12:19: +A bare `(pointer: coarse)` rule hid task-detail handles on every touch-primary device, +including tablets that must be movable and resizable. The JS tablet-touch discriminator +composes physical screen class, touch capability, and the 768px boundary, so phone sheets +and desktop hybrids retain their existing geometry while tablet touch gets active targets. +The shared scrollbar gutter stays in place: it keeps hosted scrollbars clear of east handles. */ -@media (min-width: 769px) and (max-width: 1024px) { - .floating-window--task-detail .floating-window__body { - margin-inline-end: 0; - } - - .floating-window--task-detail .floating-window__resize-handle { - display: none; - } +.floating-window--touch-geometry { + overflow: visible; } /* -FNXC:FloatingWindow 2026-07-25-00:00: -The tablet carve-out above is width-gated at 1024px, so a landscape tablet (iPad Air/Pro report 1180-1366 CSS px) fell back to the desktop contract and kept FN-8015's `margin-inline-end: var(--space-lg)` body gutter — content stopped ~17px short of the right edge while the left edge stayed flush, reading as unexplained dead space on the right of the task pop-up. -Width cannot answer this: the gutter exists to keep a hosted scrollbar out of the east/north-east/south-east resize hot zones, which only matters when a pointer can actually grab them. Gate the carve-out on the input device instead, so every touch-primary tablet gets symmetric insets at any width. `(pointer: coarse)` is primary-input only, so a touchscreen laptop driven by a trackpad still reports `fine` and keeps desktop resize clearance. -Kept as a separate block rather than folded into the width query so the existing 769-1024px contract stays independently addressable. +FNXC:ModalTouchGeometry 2026-07-26-12:19: +The painted borders remain mouse-sized, but tablet fingers receive FN-8602's shared +44px effective target outside the panel. Edges begin beyond corner targets so they do +not cover close controls, hosted footer actions, or scrollable content. */ -@media (pointer: coarse) { - .floating-window--task-detail .floating-window__body { - margin-inline-end: 0; - } - - .floating-window--task-detail .floating-window__resize-handle { - display: none; - } +.floating-window--touch-geometry .floating-window__resize-handle { + z-index: 3; + touch-action: none; } /* +FNXC:ModalTouchGeometry 2026-07-26-12:34: +A headerless window delegates dragging to a caller-owned header, so that resolved element must +receive the same tablet-only target floor as FloatingWindow's built-in header. `min-block-size` +enlarges only the interactive header box when necessary; it does not thicken resize borders or +extend an invisible target into hosted content, close controls, or footer actions. +*/ +.floating-window--touch-geometry .floating-window__delegated-drag-handle { + box-sizing: border-box; + min-block-size: var(--modal-resize-touch-target); + cursor: grab; + user-select: none; + touch-action: none; +} + +.floating-window--touch-geometry .floating-window__delegated-drag-handle:active { + cursor: grabbing; +} + +.floating-window--touch-geometry .floating-window__resize-handle--n, +.floating-window--touch-geometry .floating-window__resize-handle--s { + left: var(--modal-resize-touch-target); + right: var(--modal-resize-touch-target); + height: var(--modal-resize-touch-target); +} + +.floating-window--touch-geometry .floating-window__resize-handle--n { + top: calc((var(--modal-resize-touch-target) - var(--space-sm)) * -1); +} + +.floating-window--touch-geometry .floating-window__resize-handle--s { + bottom: calc((var(--modal-resize-touch-target) - var(--space-sm)) * -1); +} + +.floating-window--touch-geometry .floating-window__resize-handle--e, +.floating-window--touch-geometry .floating-window__resize-handle--w { + top: var(--modal-resize-touch-target); + bottom: var(--modal-resize-touch-target); + width: var(--modal-resize-touch-target); +} + +.floating-window--touch-geometry .floating-window__resize-handle--e { + right: calc((var(--modal-resize-touch-target) - var(--space-sm)) * -1); +} + +.floating-window--touch-geometry .floating-window__resize-handle--w { + left: calc((var(--modal-resize-touch-target) - var(--space-sm)) * -1); +} + +.floating-window--touch-geometry .floating-window__resize-handle--ne, +.floating-window--touch-geometry .floating-window__resize-handle--nw, +.floating-window--touch-geometry .floating-window__resize-handle--se, +.floating-window--touch-geometry .floating-window__resize-handle--sw { + width: var(--modal-resize-touch-target); + height: var(--modal-resize-touch-target); +} + +.floating-window--touch-geometry .floating-window__resize-handle--ne { + top: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1); + right: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1); +} + +.floating-window--touch-geometry .floating-window__resize-handle--nw { + top: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1); + left: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1); +} + +.floating-window--touch-geometry .floating-window__resize-handle--se { + bottom: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1); + right: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1); +} + +.floating-window--touch-geometry .floating-window__resize-handle--sw { + bottom: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1); + left: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1); +} + +/* +FNXC:ModalTouchGeometry 2026-07-26-12:19: +Phone sheets are strictly below 768px. At exactly 768px this query must not match so +`isTabletTouchViewport` can expose the active tablet touch targets without a cascade conflict. + FNXC:ChatModal 2026-06-22-14:49: On mobile/narrow app viewports, opening Quick Chat should present the full Chat modal as a full-screen sheet instead of a small draggable desktop window. Scope this to the chat FloatingWindow and override the inline desktop geometry only at the mobile breakpoint; desktop pop-out behavior remains movable/resizable. */ -@media (max-width: 768px) { +@media (max-width: 767.98px) { /* FNXC:FloatingWindow 2026-07-12-17:35: Mobile keeps the global `styles.css` pan-y lockdown so the dashboard cannot drift, but movable FloatingWindow headers must still resolve to an effective `touch-action: none`. Reassert the drag-handle contract at the mobile breakpoint, excluding full-screen sheet variants, so a single-finger header drag stays on the captured pointermove stream instead of being intersected back into page pan by the ancestor chain. Desktop drag/resize and mobile sheet variants are unchanged. diff --git a/packages/dashboard/app/components/FloatingWindow.tsx b/packages/dashboard/app/components/FloatingWindow.tsx index 0b2f9c15ea..4871e06e96 100644 --- a/packages/dashboard/app/components/FloatingWindow.tsx +++ b/packages/dashboard/app/components/FloatingWindow.tsx @@ -10,7 +10,7 @@ import { } from "react"; import { createPortal } from "react-dom"; import { X } from "lucide-react"; -import { isFullScreenSheetViewport, isShortViewport } from "../hooks/useViewportMode"; +import { isFullScreenSheetViewport, isShortViewport, isTabletTouchViewport, useViewportMode } from "../hooks/useViewportMode"; import { currentFloatingZ, currentTaskDetailFloatingZ, nextFloatingZ, nextTaskDetailFloatingZ } from "./floatingWindowStack"; import "./FloatingWindow.css"; @@ -198,6 +198,14 @@ export function FloatingWindow({ ariaLabel, }: FloatingWindowProps) { const resolvedMinSize: FloatingWindowSize = minSize ?? { width: DEFAULT_MIN_WIDTH, height: DEFAULT_MIN_HEIGHT }; + const viewportMode = useViewportMode(); + /* + FNXC:ModalTouchGeometry 2026-07-26-12:19: + Tablet touch geometry must use FN-8602's physical-screen-aware discriminator, not a bare + coarse-pointer query. Phones remain full-screen sheets and desktop hybrids retain their exact + mouse geometry; a known touch tablet at 768px is the one surface that receives enlarged targets. + */ + const hasTabletTouchGeometry = isTabletTouchViewport(viewportMode); const initialGeometry = useRef<{ size: FloatingWindowSize; position: FloatingWindowPosition } | null>(null); /* FNXC:ModalGeometryPersistence 2026-07-16-00:40: @@ -269,6 +277,14 @@ export function FloatingWindow({ (event: ReactPointerEvent) => { if ((event.target as HTMLElement).closest("button")) return; event.preventDefault(); + event.stopPropagation(); + /* + FNXC:ModalTouchGeometry 2026-07-26-12:19: + A drag owns one captured pointer until matching up/cancel or unmount. Tear down any + interrupted gesture before claiming this header so touch scroll, outside dismissal, and a + second finger cannot retain listeners, selection suppression, or stale animation frames. + */ + dragTeardownRef.current?.(); bringToFront(); const captureTarget = event.currentTarget; const pointerId = event.pointerId; @@ -285,6 +301,7 @@ export function FloatingWindow({ const handlePointerMove = (moveEvent: PointerEvent) => { if (moveEvent.pointerId !== pointerId) return; + moveEvent.preventDefault(); latest = { x: startPosition.x + moveEvent.clientX - startX, y: startPosition.y + moveEvent.clientY - startY }; if (frame) return; frame = requestAnimationFrame(() => { @@ -298,7 +315,9 @@ export function FloatingWindow({ captureTarget.removeEventListener("pointerup", handlePointerUp); captureTarget.removeEventListener("pointercancel", handlePointerUp); }; - function handlePointerUp() { + function handlePointerUp(upEvent: PointerEvent) { + if (upEvent.pointerId !== pointerId) return; + upEvent.preventDefault(); if (frame) cancelAnimationFrame(frame); setPosition(clampPosition(latest, currentSize)); document.body.style.userSelect = previousUserSelect; @@ -330,10 +349,34 @@ export function FloatingWindow({ [dragHandleSelector, handleDragPointerDown, hideHeader] ); + /* + FNXC:ModalTouchGeometry 2026-07-26-12:34: + Headerless FloatingWindows delegate dragging to caller-owned headers (notably task-detail + pop-outs). The resolved element, rather than only FloatingWindow's optional built-in header, + must receive the shared tablet touch marker and hit-area class so every drag path has the same + >=44px contract without a second gesture implementation. + */ + useLayoutEffect(() => { + if (!hasTabletTouchGeometry || !hideHeader || !dragHandleSelector) return; + const delegatedHandle = panelRef.current?.querySelector(dragHandleSelector); + if (!delegatedHandle) return; + + const previousTarget = delegatedHandle.getAttribute("data-resize-hit-target"); + delegatedHandle.classList.add("floating-window__delegated-drag-handle"); + delegatedHandle.setAttribute("data-resize-hit-target", "true"); + + return () => { + delegatedHandle.classList.remove("floating-window__delegated-drag-handle"); + if (previousTarget === null) delegatedHandle.removeAttribute("data-resize-hit-target"); + else delegatedHandle.setAttribute("data-resize-hit-target", previousTarget); + }; + }, [children, dragHandleSelector, hasTabletTouchGeometry, hideHeader]); + const handleResizePointerDown = useCallback( (event: ReactPointerEvent, direction: ResizeDirection) => { event.preventDefault(); event.stopPropagation(); + dragTeardownRef.current?.(); bringToFront(); const captureTarget = event.currentTarget; const pointerId = event.pointerId; @@ -351,6 +394,7 @@ export function FloatingWindow({ const handlePointerMove = (moveEvent: PointerEvent) => { if (moveEvent.pointerId !== pointerId) return; + moveEvent.preventDefault(); const dx = moveEvent.clientX - startX; const dy = moveEvent.clientY - startY; const nextSize = clampSize( @@ -379,7 +423,9 @@ export function FloatingWindow({ captureTarget.removeEventListener("pointerup", handlePointerUp); captureTarget.removeEventListener("pointercancel", handlePointerUp); }; - function handlePointerUp() { + function handlePointerUp(upEvent: PointerEvent) { + if (upEvent.pointerId !== pointerId) return; + upEvent.preventDefault(); if (frame) cancelAnimationFrame(frame); setSize(latestSize); setPosition(clampPosition(latestPosition, latestSize)); @@ -495,7 +541,7 @@ export function FloatingWindow({ >
handleResizePointerDown(event, direction)} @@ -516,6 +563,7 @@ export function FloatingWindow({
{title}
diff --git a/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx b/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx index 2a333b73ed..0263206dce 100644 --- a/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx +++ b/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx @@ -88,7 +88,7 @@ function mediaBlockFor(css: string, query: string): string { function setSheetViewport(isSheetWidth: boolean): void { vi.stubGlobal("matchMedia", vi.fn((query: string) => ({ - matches: query === "(max-width: 768px)" ? isSheetWidth : query === "(max-height: 480px)", + matches: query === "(max-width: 767.98px)" ? isSheetWidth : query === "(max-height: 480px)", media: query, onchange: null, addEventListener: vi.fn(), @@ -151,9 +151,9 @@ describe("FloatingWindow", () => { expect(stylesCss).toContain("width: 8px;"); expect(bodyRule).toContain("overflow: auto;"); expect(bodyRule).toContain("margin-inline-end: var(--space-lg);"); - expect(cssRuleFor(floatingWindowCss, ".floating-window__resize-handle--e")).toContain("right: 0;"); - expect(cssRuleFor(floatingWindowCss, ".floating-window__resize-handle--ne")).toContain("right: 0;"); - expect(cssRuleFor(floatingWindowCss, ".floating-window__resize-handle--se")).toContain("right: 0;"); + expect(cssRuleContaining(floatingWindowCss, ".floating-window__resize-handle--e", "right: 0;")).toContain("right: 0;"); + expect(cssRuleContaining(floatingWindowCss, ".floating-window__resize-handle--ne", "right: 0;")).toContain("right: 0;"); + expect(cssRuleContaining(floatingWindowCss, ".floating-window__resize-handle--se", "right: 0;")).toContain("right: 0;"); // No shared caller may move a right handle back into the reserved scrollbar // gutter, nor override the body gutter, AT DESKTOP WIDTHS. Mobile full-screen @@ -208,68 +208,16 @@ describe("FloatingWindow", () => { width, and that it stays scoped to task-detail so other floating-window callers (whose right resize handles remain live) keep their scrollbar clearance. */ - it("removes task-detail resize clearance on touch-primary pointers at any width", () => { - const coarseBlock = mediaBlockFor(floatingWindowCss, "(pointer: coarse)"); + it("uses the tablet-touch discriminator instead of bare coarse-pointer suppression", () => { + expect(floatingWindowCss).not.toContain("@media (pointer: coarse)"); + expect(floatingWindowCss).not.toContain("max-width: 768px"); + expect(floatingWindowCss).toContain("@media (max-width: 767.98px)"); + expect(floatingWindowCss).toContain(".floating-window--touch-geometry .floating-window__resize-handle"); + expect(floatingWindowCss).toContain("width: var(--modal-resize-touch-target);"); + expect(floatingWindowCss).toContain("margin-inline-end: var(--space-lg);"); - expect(coarseBlock).not.toBe(""); - expect(cssRuleFor(coarseBlock, ".floating-window--task-detail .floating-window__body")).toContain("margin-inline-end: 0;"); - expect(cssRuleFor(coarseBlock, ".floating-window--task-detail .floating-window__resize-handle")).toContain("display: none;"); - - // The carve-out is task-detail only: every other shared caller keeps the gutter. - for (const callerClass of [ - "floating-window--automation", - "floating-window--mission-interview", - "floating-window--pr-create", - "floating-window--file-browser", - "floating-window--workflow-editor", - "artifacts-gallery-window", - ]) { - expect(cssRulesForClass(coarseBlock, callerClass), callerClass).toHaveLength(0); - } - - // No width bound may creep back into the coarse-pointer query. - expect(floatingWindowCss).toContain("@media (pointer: coarse) {"); - }); - - it("removes only tablet task-detail resize clearance and handles for empty and populated popups", () => { - const tabletBlock = mediaBlockFor(floatingWindowCss, "(min-width: 769px) and (max-width: 1024px)"); - const mobileBlock = mediaBlockFor(floatingWindowCss, "(max-width: 768px)"); - const desktopCss = stripAtMediaBlocks(floatingWindowCss); - - expect(cssRuleFor(tabletBlock, ".floating-window--task-detail .floating-window__body")).toContain("margin-inline-end: 0;"); - expect(cssRuleFor(tabletBlock, ".floating-window--task-detail .floating-window__resize-handle")).toContain("display: none;"); - expect(desktopCss.match(/(?:^|\n)\.floating-window__body\s*\{[^}]*\}/)?.[0]).toContain("margin-inline-end: var(--space-lg);"); - expect(cssRulesForClass(desktopCss, "floating-window--task-detail").some((rule) => /margin-inline-end|display:\s*none/.test(rule))).toBe(false); - - // Mobile remains a full-screen sheet with its independent no-gutter/no-handle contract. - expect(cssRuleFor(mobileBlock, ".floating-window--task-detail")).toContain("width: 100vw !important;"); - expect(cssRuleFor(mobileBlock, ".floating-window--task-detail")).toContain("border-radius: 0;"); - expect(cssRuleFor(mobileBlock, ".floating-window--task-detail .floating-window__body")).toContain("margin-inline-end: 0;"); - expect(cssRuleFor(mobileBlock, ".floating-window--task-detail .floating-window__resize-handle")).toContain("display: none;"); - expect(mobileBlock).toContain(".floating-window--task-detail .task-detail-content--embedded > .modal-header"); - expect(mobileBlock).toContain("cursor: default;"); - expect(mobileBlock).toContain("touch-action: auto;"); - - Object.defineProperty(window, "innerWidth", { configurable: true, value: 834 }); - Object.defineProperty(window, "innerHeight", { configurable: true, value: 1112 }); - setSheetViewport(false); - render( - <> - {}} className="floating-window--task-detail"> -
- - {}} className="floating-window--task-detail"> -
{Array.from({ length: 40 }, (_, index) =>

Scrollable detail {index}

)}
-
- , - ); - - for (const key of ["tablet-empty", "tablet-populated"]) { - const panel = screen.getByTestId(`floating-window-${key}`); - expect(panel).toHaveClass("floating-window--task-detail"); - expect(Number.parseFloat(panel.style.width)).toBeLessThanOrEqual(834); - expect(screen.getByTestId(`floating-window-body-${key}`)).toHaveClass("floating-window__body"); - } + const phoneBlock = mediaBlockFor(floatingWindowCss, "(max-width: 767.98px)"); + expect(cssRuleFor(phoneBlock, ".floating-window--task-detail .floating-window__resize-handle")).toContain("display: none;"); }); it("keeps task-detail long content clear of right handles while preserving short-content right-edge resize", () => { @@ -1053,7 +1001,7 @@ describe("FloatingWindow", () => { }); it("makes only the mobile chat floating window full-screen", () => { - const mobileBlock = floatingWindowCss.match(/@media\s*\(max-width:\s*768px\)\s*\{[\s\S]*?\.floating-window--chat \.chat-view\s*\{[\s\S]*?\n\}/)?.[0]; + const mobileBlock = floatingWindowCss.match(/@media\s*\(max-width:\s*767\.98px\)\s*\{[\s\S]*?\.floating-window--chat \.chat-view\s*\{[\s\S]*?\n\}/)?.[0]; expect(mobileBlock).toContain(".floating-window--chat"); expect(mobileBlock).toContain("width: 100vw !important;"); diff --git a/packages/dashboard/app/components/__tests__/FloatingWindow.touch-geometry.test.tsx b/packages/dashboard/app/components/__tests__/FloatingWindow.touch-geometry.test.tsx new file mode 100644 index 0000000000..3e33509cf5 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/FloatingWindow.touch-geometry.test.tsx @@ -0,0 +1,136 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const viewport = vi.hoisted(() => ({ tabletTouch: false })); + +vi.mock("../../hooks/useViewportMode", async () => { + const actual = await vi.importActual("../../hooks/useViewportMode"); + return { + ...actual, + useViewportMode: () => viewport.tabletTouch ? "tablet" : "desktop", + isTabletTouchViewport: () => viewport.tabletTouch, + }; +}); + +import { FloatingWindow } from "../FloatingWindow"; + +const directions = ["n", "s", "e", "w", "ne", "nw", "se", "sw"] as const; + +function renderWindow(key = "touch-geometry") { + return render( + {}} + defaultSize={{ width: 320, height: 240 }} + defaultPosition={{ x: 80, y: 90 }} + minSize={{ width: 240, height: 180 }} + persistGeometryKey={`fusion:${key}`} + > +
content
+
, + ); +} + +describe("FloatingWindow tablet touch geometry", () => { + beforeEach(() => { + localStorage.clear(); + viewport.tabletTouch = false; + }); + + afterEach(() => { + document.body.style.userSelect = ""; + }); + + it("adds the shared hit-target contract to the eight handles and drag handle only for tablet touch", () => { + const { rerender } = renderWindow(); + const panel = screen.getByTestId("floating-window-touch-geometry"); + expect(panel).not.toHaveClass("floating-window--touch-geometry"); + expect(document.querySelectorAll("[data-resize-hit-target='true']")).toHaveLength(0); + + viewport.tabletTouch = true; + rerender( + {}} defaultSize={{ width: 320, height: 240 }} defaultPosition={{ x: 80, y: 90 }} minSize={{ width: 240, height: 180 }}> +
content
+
, + ); + + expect(screen.getByTestId("floating-window-touch-geometry")).toHaveClass("floating-window--touch-geometry"); + for (const direction of directions) { + expect(screen.getByTestId(`floating-window-resize-${direction}`)).toHaveAttribute("data-resize-hit-target", "true"); + } + expect(screen.getByTestId("floating-window-drag-handle-touch-geometry")).toHaveAttribute("data-resize-hit-target", "true"); + }); + + it("applies the touch contract and drag gesture to a headerless delegated handle", () => { + viewport.tabletTouch = true; + render( + {}} + hideHeader + dragHandleSelector=".delegated-drag-handle" + defaultSize={{ width: 320, height: 240 }} + defaultPosition={{ x: 80, y: 90 }} + > +
Task detail header
+
, + ); + + const panel = screen.getByTestId("floating-window-delegated-touch"); + const handle = screen.getByText("Task detail header"); + Object.defineProperty(handle, "setPointerCapture", { configurable: true, value: vi.fn() }); + Object.defineProperty(handle, "releasePointerCapture", { configurable: true, value: vi.fn() }); + + expect(handle).toHaveAttribute("data-resize-hit-target", "true"); + expect(handle).toHaveClass("floating-window__delegated-drag-handle"); + fireEvent.pointerDown(handle, { pointerType: "touch", pointerId: 1, clientX: 100, clientY: 100 }); + fireEvent.pointerMove(handle, { pointerType: "touch", pointerId: 1, clientX: 132, clientY: 124 }); + fireEvent.pointerUp(handle, { pointerType: "touch", pointerId: 1, clientX: 132, clientY: 124 }); + + expect(panel.style.left).toBe("112px"); + expect(panel.style.top).toBe("114px"); + }); + + it("filters another finger and commits a captured touch resize with clamped geometry", () => { + viewport.tabletTouch = true; + renderWindow("resize"); + const panel = screen.getByTestId("floating-window-resize"); + const handle = screen.getByTestId("floating-window-resize-se"); + const setPointerCapture = vi.fn(); + const releasePointerCapture = vi.fn(); + Object.defineProperty(handle, "setPointerCapture", { configurable: true, value: setPointerCapture }); + Object.defineProperty(handle, "releasePointerCapture", { configurable: true, value: releasePointerCapture }); + + fireEvent.pointerDown(handle, { pointerType: "touch", pointerId: 1, clientX: 400, clientY: 330 }); + fireEvent.pointerMove(handle, { pointerType: "touch", pointerId: 2, clientX: 650, clientY: 600 }); + expect(panel.style.width).toBe("320px"); + fireEvent.pointerMove(handle, { pointerType: "touch", pointerId: 1, clientX: 440, clientY: 370 }); + fireEvent.pointerUp(handle, { pointerType: "touch", pointerId: 1, clientX: 440, clientY: 370 }); + + expect(setPointerCapture).toHaveBeenCalledWith(1); + expect(releasePointerCapture).toHaveBeenCalledWith(1); + expect(panel.style.width).toBe("360px"); + expect(panel.style.height).toBe("280px"); + expect(JSON.parse(localStorage.getItem("fusion:resize") ?? "{}")).toMatchObject({ size: { width: 360, height: 280 } }); + }); + + it("tears down a cancelled touch drag without retaining selection suppression", () => { + viewport.tabletTouch = true; + const { unmount } = renderWindow("cancel"); + const header = screen.getByTestId("floating-window-drag-handle-cancel"); + Object.defineProperty(header, "setPointerCapture", { configurable: true, value: vi.fn() }); + Object.defineProperty(header, "releasePointerCapture", { configurable: true, value: vi.fn() }); + + fireEvent.pointerDown(header, { pointerType: "touch", pointerId: 1, clientX: 120, clientY: 120 }); + expect(document.body.style.userSelect).toBe("none"); + fireEvent.pointerCancel(header, { pointerType: "touch", pointerId: 1, clientX: 120, clientY: 120 }); + expect(document.body.style.userSelect).toBe(""); + + fireEvent.pointerDown(header, { pointerType: "touch", pointerId: 2, clientX: 120, clientY: 120 }); + expect(document.body.style.userSelect).toBe("none"); + unmount(); + expect(document.body.style.userSelect).toBe(""); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/FloatingWindowStack.cross-type.test.tsx b/packages/dashboard/app/components/__tests__/FloatingWindowStack.cross-type.test.tsx index 6fbf6b8fa3..a378d2d7ab 100644 --- a/packages/dashboard/app/components/__tests__/FloatingWindowStack.cross-type.test.tsx +++ b/packages/dashboard/app/components/__tests__/FloatingWindowStack.cross-type.test.tsx @@ -52,7 +52,7 @@ describe("floatingWindowStack (cross-type)", () => { expect(Number(dockPanel.style.zIndex)).toBeGreaterThan(Number(fwPanel.style.zIndex)); // Tapping the older FloatingWindow raises it above the dock pop-out — across the type boundary. - fireEvent.pointerDown(fwPanel); + fireEvent.pointerDown(fwPanel, { pointerType: "touch", pointerId: 1 }); expect(Number(fwPanel.style.zIndex)).toBeGreaterThan(Number(dockPanel.style.zIndex)); // Tapping the dock pop-out raises it back above the FloatingWindow. diff --git a/packages/dashboard/app/hooks/__tests__/useViewportMode.test.ts b/packages/dashboard/app/hooks/__tests__/useViewportMode.test.ts index 1640da5873..395705116b 100644 --- a/packages/dashboard/app/hooks/__tests__/useViewportMode.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useViewportMode.test.ts @@ -4,6 +4,7 @@ import { getViewportMode, isFullScreenSheetViewport, isMobileViewport, isTabletT const TABLET_MEDIA_QUERY = "(min-width: 769px) and (max-width: 1024px)"; const MOBILE_WIDTH_MEDIA_QUERY = "(max-width: 768px)"; +const FULL_SCREEN_SHEET_WIDTH_MEDIA_QUERY = "(max-width: 767.98px)"; const MOBILE_HEIGHT_MEDIA_QUERY = "(max-height: 480px)"; const PHONE_WIDTH_MEDIA_QUERY = "(max-width: 600px)"; const originalScreenDescriptor = Object.getOwnPropertyDescriptor(window, "screen"); @@ -16,7 +17,7 @@ function stubMissingScreen() { Object.defineProperty(window, "screen", { configurable: true, value: undefined }); } -function installViewportMedia(options: { width: boolean; height: boolean; tablet: boolean }) { +function installViewportMedia(options: { width: boolean; height: boolean; tablet: boolean; sheetWidth?: boolean }) { vi.stubGlobal( "matchMedia", vi.fn((query: string) => ({ @@ -25,7 +26,9 @@ function installViewportMedia(options: { width: boolean; height: boolean; tablet ? options.width || options.height : query === MOBILE_WIDTH_MEDIA_QUERY ? options.width - : query === MOBILE_HEIGHT_MEDIA_QUERY + : query === FULL_SCREEN_SHEET_WIDTH_MEDIA_QUERY + ? (options.sheetWidth ?? options.width) + : query === MOBILE_HEIGHT_MEDIA_QUERY ? options.height : query === TABLET_MEDIA_QUERY ? options.tablet @@ -48,6 +51,7 @@ function createViewportMediaMock(initial: { mobile: boolean; tablet: boolean }) const matches = new Map([ [MOBILE_MEDIA_QUERY, initial.mobile], [MOBILE_WIDTH_MEDIA_QUERY, initial.mobile], + [FULL_SCREEN_SHEET_WIDTH_MEDIA_QUERY, initial.mobile], [MOBILE_HEIGHT_MEDIA_QUERY, false], [TABLET_MEDIA_QUERY, initial.tablet], ]); @@ -78,6 +82,7 @@ function createViewportMediaMock(initial: { mobile: boolean; tablet: boolean }) matches.set(query, nextMatches); if (query === MOBILE_MEDIA_QUERY) { matches.set(MOBILE_WIDTH_MEDIA_QUERY, nextMatches); + matches.set(FULL_SCREEN_SHEET_WIDTH_MEDIA_QUERY, nextMatches); } }, dispatchChange: () => { @@ -270,16 +275,23 @@ describe("useViewportMode", () => { const originalMaxTouchPoints = Object.getOwnPropertyDescriptor(navigator, "maxTouchPoints"); stubScreen(768, 1024); Object.defineProperty(navigator, "maxTouchPoints", { configurable: true, value: 1 }); - installViewportMedia({ width: true, height: false, tablet: false }); + installViewportMedia({ width: true, sheetWidth: false, height: false, tablet: false }); try { expect(isMobileViewport()).toBe(false); + expect(isFullScreenSheetViewport()).toBe(false); expect(getViewportMode()).toBe("tablet"); } finally { if (originalMaxTouchPoints) Object.defineProperty(navigator, "maxTouchPoints", originalMaxTouchPoints); } }); + it("keeps 767px phone sheets below the 768px tablet boundary", () => { + stubScreen(390, 844); + installViewportMedia({ width: true, sheetWidth: true, height: false, tablet: false }); + expect(isFullScreenSheetViewport()).toBe(true); + }); + it("keeps desktop mode when only the short-height clause matches on a desktop-class screen", () => { stubScreen(1920, 1080); installViewportMedia({ width: false, height: true, tablet: false }); diff --git a/packages/dashboard/app/hooks/useViewportMode.ts b/packages/dashboard/app/hooks/useViewportMode.ts index 43ad02b187..de8a4678b5 100644 --- a/packages/dashboard/app/hooks/useViewportMode.ts +++ b/packages/dashboard/app/hooks/useViewportMode.ts @@ -11,6 +11,7 @@ export type ViewportMode = "mobile" | "tablet" | "desktop"; export const MOBILE_MEDIA_QUERY = "(max-width: 768px), (max-height: 480px)"; const MOBILE_WIDTH_MEDIA_QUERY = "(max-width: 768px)"; +const FULL_SCREEN_SHEET_WIDTH_MEDIA_QUERY = "(max-width: 767.98px)"; const MOBILE_HEIGHT_MEDIA_QUERY = "(max-height: 480px)"; /* @@ -31,9 +32,13 @@ FNXC:ModalGeometryPersistence 2026-07-15-19:30: Full-screen FloatingWindow sheets use only the CSS width breakpoint. This deliberately diverges from `isMobileViewport()`: its short landscape-phone clause still renders movable windows, whose desktop geometry must continue to restore and persist. + +FNXC:ModalTouchGeometry 2026-07-26-12:19: +The sheet boundary is strictly below 768px so JS geometry persistence agrees with FloatingWindow CSS: +a 768px known tablet touch viewport is movable/resizable, never a phone sheet. */ export function isFullScreenSheetViewport(): boolean { - return typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia(MOBILE_WIDTH_MEDIA_QUERY).matches; + return typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia(FULL_SCREEN_SHEET_WIDTH_MEDIA_QUERY).matches; } /** Returns whether the CSS short-viewport breakpoint is active. */ diff --git a/packages/dashboard/app/task-modal-touch-resize-e2e-fixture.tsx b/packages/dashboard/app/task-modal-touch-resize-e2e-fixture.tsx index 7c886383ad..eb390d10a3 100644 --- a/packages/dashboard/app/task-modal-touch-resize-e2e-fixture.tsx +++ b/packages/dashboard/app/task-modal-touch-resize-e2e-fixture.tsx @@ -4,6 +4,8 @@ import i18n from "i18next"; import { I18nextProvider, initReactI18next } from "react-i18next"; import "./styles.css"; import "./components/TaskDetailModal.css"; +import "./components/FloatingWindow.css"; +import { FloatingWindow } from "./components/FloatingWindow"; import { useModalResizePersist } from "./hooks/useModalResizePersist"; import { isTabletTouchViewport, useViewportMode } from "./hooks/useViewportMode"; import { NewTaskModal } from "./components/NewTaskModal"; @@ -43,10 +45,45 @@ function TaskDetailResizeHarness() {
; } +function FloatingWindowHarness() { + return undefined} + className="floating-window--task-detail" + defaultSize={{ width: 560, height: 480 }} + defaultPosition={{ x: 80, y: 80 }} + minSize={{ width: 320, height: 240 }} + persistGeometryKey="fusion:fn-8605-floating" + suspendGeometryPersistenceOnMobile + > +
Floating task detail body
+
; +} + +function HeaderlessFloatingWindowHarness() { + return undefined} + hideHeader + dragHandleSelector=".fn-8605-delegated-drag-handle" + className="floating-window--task-detail" + defaultSize={{ width: 560, height: 480 }} + defaultPosition={{ x: 80, y: 80 }} + minSize={{ width: 320, height: 240 }} + persistGeometryKey="fusion:fn-8605-headerless-floating" + suspendGeometryPersistenceOnMobile + > +
Headerless task detail
+
Floating task detail body
+
; +} + function Fixture() { return - {surface === "task-detail" ? : : surface === "floating-window-headerless" ? : surface === "task-detail" ? : undefined} diff --git a/packages/dashboard/e2e/__screenshots__/fn-8605/phone-fullscreen.png b/packages/dashboard/e2e/__screenshots__/fn-8605/phone-fullscreen.png new file mode 100644 index 0000000000..15502bcba2 Binary files /dev/null and b/packages/dashboard/e2e/__screenshots__/fn-8605/phone-fullscreen.png differ diff --git a/packages/dashboard/e2e/__screenshots__/fn-8605/tablet-after.png b/packages/dashboard/e2e/__screenshots__/fn-8605/tablet-after.png new file mode 100644 index 0000000000..04058a31b6 Binary files /dev/null and b/packages/dashboard/e2e/__screenshots__/fn-8605/tablet-after.png differ diff --git a/packages/dashboard/e2e/__screenshots__/fn-8605/tablet-before.png b/packages/dashboard/e2e/__screenshots__/fn-8605/tablet-before.png new file mode 100644 index 0000000000..3a9ae411b9 Binary files /dev/null and b/packages/dashboard/e2e/__screenshots__/fn-8605/tablet-before.png differ diff --git a/packages/dashboard/src/__tests__/task-modal-touch-resize-browser.test.ts b/packages/dashboard/src/__tests__/task-modal-touch-resize-browser.test.ts index ab6f4258e9..f0af7d8a6e 100644 --- a/packages/dashboard/src/__tests__/task-modal-touch-resize-browser.test.ts +++ b/packages/dashboard/src/__tests__/task-modal-touch-resize-browser.test.ts @@ -19,6 +19,7 @@ const browserCandidates = process.platform === "darwin" : ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser"]; const executablePath = [process.env.FUSION_BROWSER_SMOKE_BROWSER, process.env.CHROME_BIN, ...browserCandidates].find((candidate): candidate is string => Boolean(candidate) && existsSync(candidate)); const screenshots = path.resolve(process.cwd(), "e2e/__screenshots__/fn-8602"); +const floatingWindowScreenshots = path.resolve(process.cwd(), "e2e/__screenshots__/fn-8605"); async function touchDrag(cdp: Cdp, point: Point, delta = { x: 48, y: 36 }) { await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x: point.x, y: point.y, id: 1 }] }); @@ -136,6 +137,88 @@ describe.runIf(executablePath)("Task modal tablet touch resize browser regressio }, 30_000); } + for (const [width, height] of [[768, 1024], [820, 1180]] as const) { + it(`hits, resizes, and drags FloatingWindow at ${width}px with CDP touch`, async () => { + const page = await browser.newPage({ viewport: { width, height } }); + const cdp = await page.context().newCDPSession(page); + await setTabletMetrics(cdp, width, height); + await page.goto(`${baseUrl}app/task-modal-touch-resize-e2e-fixture.html?surface=floating-window&reset=1`); + await page.waitForTimeout(250); + expect(await page.evaluate(() => window.scrollX === 0 && window.scrollY === 0)).toBe(true); + expect(await page.evaluate(() => document.querySelectorAll("[data-resize-hit-target='true']").length)).toBe(9); + await mkdir(floatingWindowScreenshots, { recursive: true }); + if (width === 820) await page.screenshot({ path: path.join(floatingWindowScreenshots, "tablet-before.png") }); + + const resizeSelector = "[data-testid='floating-window-resize-se']"; + const resizePoint = await targetCenter(page, resizeSelector); + expect(await page.evaluate((point) => document.elementFromPoint(point.x, point.y)?.getAttribute("data-resize-hit-target"), resizePoint)).toBe("true"); + const beforeResize = await rect(page, "[data-testid='floating-window-fn-8605-floating']"); + await touchDrag(cdp, resizePoint); + await page.waitForTimeout(100); + const afterResize = await rect(page, "[data-testid='floating-window-fn-8605-floating']"); + expect(afterResize.width).toBeGreaterThan(beforeResize.width); + expect(afterResize.height).toBeGreaterThan(beforeResize.height); + expect(afterResize.x).toBeGreaterThanOrEqual(0); + expect(afterResize.y).toBeGreaterThanOrEqual(0); + expect(afterResize.width).toBeLessThanOrEqual(width - 32); + expect(afterResize.height).toBeLessThanOrEqual(height - 32); + expect(await page.evaluate(() => localStorage.getItem("fusion:fn-8605-floating"))).not.toBeNull(); + + const headerPoint = await targetCenter(page, "[data-testid='floating-window-drag-handle-fn-8605-floating']"); + const beforeDrag = await rect(page, "[data-testid='floating-window-fn-8605-floating']"); + await touchDrag(cdp, headerPoint, { x: 28, y: 24 }); + await page.waitForTimeout(100); + const afterDrag = await rect(page, "[data-testid='floating-window-fn-8605-floating']"); + expect(afterDrag.x).not.toBe(beforeDrag.x); + expect(afterDrag.y).not.toBe(beforeDrag.y); + expect(afterDrag.width).toBe(beforeDrag.width); + expect(afterDrag.height).toBe(beforeDrag.height); + if (width === 820) await page.screenshot({ path: path.join(floatingWindowScreenshots, "tablet-after.png") }); + await page.close(); + }, 30_000); + } + + for (const [width, height] of [[768, 1024], [820, 1180]] as const) { + it(`hits and drags a headerless delegated FloatingWindow handle at ${width}px`, async () => { + const page = await browser.newPage({ viewport: { width, height } }); + const cdp = await page.context().newCDPSession(page); + await setTabletMetrics(cdp, width, height); + await page.goto(`${baseUrl}app/task-modal-touch-resize-e2e-fixture.html?surface=floating-window-headerless&reset=1`); + await page.waitForTimeout(250); + + expect(await page.evaluate(() => document.querySelectorAll("[data-resize-hit-target='true']").length)).toBe(9); + const headerSelector = ".fn-8605-delegated-drag-handle"; + const headerPoint = await targetCenter(page, headerSelector); + expect(await page.evaluate((point) => document.elementFromPoint(point.x, point.y)?.getAttribute("data-resize-hit-target"), headerPoint)).toBe("true"); + const panelSelector = "[data-testid='floating-window-fn-8605-headerless-floating']"; + const beforeDrag = await rect(page, panelSelector); + await touchDrag(cdp, headerPoint, { x: 28, y: 24 }); + await page.waitForTimeout(100); + const afterDrag = await rect(page, panelSelector); + expect(afterDrag.x).not.toBe(beforeDrag.x); + expect(afterDrag.y).not.toBe(beforeDrag.y); + expect(afterDrag.width).toBe(beforeDrag.width); + expect(afterDrag.height).toBe(beforeDrag.height); + await page.close(); + }, 30_000); + } + + it("keeps the 767px FloatingWindow phone sheet free of active targets", async () => { + const page = await browser.newPage({ viewport: { width: 767, height: 1024 } }); + const cdp = await page.context().newCDPSession(page); + await cdp.send("Emulation.setDeviceMetricsOverride", { width: 767, height: 1024, screenWidth: 390, screenHeight: 844, deviceScaleFactor: 1, mobile: false }); + await cdp.send("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 1 }); + await page.goto(`${baseUrl}app/task-modal-touch-resize-e2e-fixture.html?surface=floating-window&reset=1`); + await page.waitForTimeout(250); + expect(await page.evaluate(() => document.querySelector("[data-resize-hit-target]") === null)).toBe(true); + expect(await page.evaluate(() => { + const panel = document.querySelector("[data-testid='floating-window-fn-8605-floating']"); + return panel ? panel.getBoundingClientRect().height >= window.innerHeight * 0.9 : false; + })).toBe(true); + await page.screenshot({ path: path.join(floatingWindowScreenshots, "phone-fullscreen.png") }); + await page.close(); + }, 30_000); + it("keeps the true-phone sheet free of active resize targets", async () => { const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); const cdp = await page.context().newCDPSession(page);