From 47087342fc81a26230e3445ac2e3b67bc6552a2a Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 26 Jul 2026 12:51:39 -0700 Subject: [PATCH] FN-8605: harden tablet touch window controls Make shared floating dashboard windows reliably movable and resizable on tablet touch viewports. - Apply a tablet-aware 44px drag and resize hit-target contract to FloatingWindow. - Preserve phone full-screen sheets below 768px while retaining desktop-hybrid geometry. - Add geometry, browser touch, and viewport boundary coverage with captured visual evidence. - Document the shared modal touch contract and release the dashboard fix. Files changed: .changeset/fn-8605-floating-window-touch.md | 7 ++ docs/dashboard-guide.md | 5 + docs/testing.md | 2 +- .../dashboard/app/components/FloatingWindow.css | 116 ++++++++++++++---- .../dashboard/app/components/FloatingWindow.tsx | 56 ++++++++- .../components/__tests__/FloatingWindow.test.tsx | 80 +++--------- .../FloatingWindow.touch-geometry.test.tsx | 136 +++++++++++++++++++++ .../FloatingWindowStack.cross-type.test.tsx | 2 +- .../app/hooks/__tests__/useViewportMode.test.ts | 18 ++- packages/dashboard/app/hooks/useViewportMode.ts | 7 +- .../app/task-modal-touch-resize-e2e-fixture.tsx | 39 +++++- .../__screenshots__/fn-8605/phone-fullscreen.png | Bin 0 -> 11393 bytes .../e2e/__screenshots__/fn-8605/tablet-after.png | Bin 0 -> 14275 bytes .../e2e/__screenshots__/fn-8605/tablet-before.png | Bin 0 -> 14308 bytes .../task-modal-touch-resize-browser.test.ts | 83 +++++++++++++ 15 files changed, 453 insertions(+), 98 deletions(-) Fusion-Task-Id: FN-8605 Fusion-Task-Lineage: 993c7ed8-c3e9-4673-b0b7-5c50746991a7 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-8605-floating-window-touch.md | 7 + docs/dashboard-guide.md | 5 + docs/testing.md | 2 +- .../app/components/FloatingWindow.css | 120 +++++++++++++--- .../app/components/FloatingWindow.tsx | 56 +++++++- .../__tests__/FloatingWindow.test.tsx | 80 ++--------- .../FloatingWindow.touch-geometry.test.tsx | 136 ++++++++++++++++++ .../FloatingWindowStack.cross-type.test.tsx | 2 +- .../hooks/__tests__/useViewportMode.test.ts | 18 ++- .../dashboard/app/hooks/useViewportMode.ts | 7 +- .../task-modal-touch-resize-e2e-fixture.tsx | 39 ++++- .../fn-8605/phone-fullscreen.png | Bin 0 -> 11393 bytes .../__screenshots__/fn-8605/tablet-after.png | Bin 0 -> 14275 bytes .../__screenshots__/fn-8605/tablet-before.png | Bin 0 -> 14308 bytes .../task-modal-touch-resize-browser.test.ts | 83 +++++++++++ 15 files changed, 455 insertions(+), 100 deletions(-) create mode 100644 .changeset/fn-8605-floating-window-touch.md create mode 100644 packages/dashboard/app/components/__tests__/FloatingWindow.touch-geometry.test.tsx create mode 100644 packages/dashboard/e2e/__screenshots__/fn-8605/phone-fullscreen.png create mode 100644 packages/dashboard/e2e/__screenshots__/fn-8605/tablet-after.png create mode 100644 packages/dashboard/e2e/__screenshots__/fn-8605/tablet-before.png 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 0000000000000000000000000000000000000000..15502bcba248b8233b5f0e0fee7abac643ff58c9 GIT binary patch literal 11393 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 Z^u}w!zP19X4~&2=ob~t_fBM(o{tNIaGSvV8 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..04058a31b61a8012a2bc6e1230add207771fa16e GIT binary patch literal 14275 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 V8%LbHfGeQ;`lffQ?mYhIKLD2mKp_AC literal 0 HcmV?d00001 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);