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) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8605-floating-window-touch.md
Normal file
7
.changeset/fn-8605-floating-window-touch.md
Normal file
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
<!-- FNXC:ModalTouchGeometryDocs 2026-07-26-12:19: FloatingWindow is the shared move/resize primitive; its tablet target contract must remain discriminator-composed so 768px tablets never collide with phone sheets. -->
|
||||
### 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<HTMLDivElement>) => {
|
||||
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<HTMLElement>(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<HTMLDivElement>, 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({
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={`floating-window${hideHeader ? " floating-window--headerless" : ""}${className ? ` ${className}` : ""}`}
|
||||
className={`floating-window${hideHeader ? " floating-window--headerless" : ""}${hasTabletTouchGeometry ? " floating-window--touch-geometry" : ""}${className ? ` ${className}` : ""}`}
|
||||
style={panelStyle}
|
||||
data-testid={`floating-window-${windowKey}`}
|
||||
onPointerDownCapture={bringToFront}
|
||||
@@ -507,6 +553,7 @@ export function FloatingWindow({
|
||||
key={direction}
|
||||
className={`floating-window__resize-handle floating-window__resize-handle--${direction}`}
|
||||
data-testid={`floating-window-resize-${direction}`}
|
||||
{...(hasTabletTouchGeometry ? { "data-resize-hit-target": "true" } : {})}
|
||||
role="separator"
|
||||
aria-label="Resize floating window"
|
||||
onPointerDown={(event) => handleResizePointerDown(event, direction)}
|
||||
@@ -516,6 +563,7 @@ export function FloatingWindow({
|
||||
<div
|
||||
className="floating-window__header"
|
||||
data-testid={`floating-window-drag-handle-${windowKey}`}
|
||||
{...(hasTabletTouchGeometry ? { "data-resize-hit-target": "true" } : {})}
|
||||
onPointerDown={handleDragPointerDown}
|
||||
>
|
||||
<div className="floating-window__title">{title}</div>
|
||||
|
||||
@@ -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(
|
||||
<>
|
||||
<FloatingWindow windowKey="tablet-empty" title="Empty task" onClose={() => {}} className="floating-window--task-detail">
|
||||
<div aria-label="empty task detail" />
|
||||
</FloatingWindow>
|
||||
<FloatingWindow windowKey="tablet-populated" title="Populated task" onClose={() => {}} className="floating-window--task-detail">
|
||||
<div aria-label="populated task detail">{Array.from({ length: 40 }, (_, index) => <p key={index}>Scrollable detail {index}</p>)}</div>
|
||||
</FloatingWindow>
|
||||
</>,
|
||||
);
|
||||
|
||||
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;");
|
||||
|
||||
@@ -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<typeof import("../../hooks/useViewportMode")>("../../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(
|
||||
<FloatingWindow
|
||||
windowKey={key}
|
||||
title="Tablet window"
|
||||
onClose={() => {}}
|
||||
defaultSize={{ width: 320, height: 240 }}
|
||||
defaultPosition={{ x: 80, y: 90 }}
|
||||
minSize={{ width: 240, height: 180 }}
|
||||
persistGeometryKey={`fusion:${key}`}
|
||||
>
|
||||
<div>content</div>
|
||||
</FloatingWindow>,
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
<FloatingWindow windowKey="touch-geometry" title="Tablet window" onClose={() => {}} defaultSize={{ width: 320, height: 240 }} defaultPosition={{ x: 80, y: 90 }} minSize={{ width: 240, height: 180 }}>
|
||||
<div>content</div>
|
||||
</FloatingWindow>,
|
||||
);
|
||||
|
||||
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(
|
||||
<FloatingWindow
|
||||
windowKey="delegated-touch"
|
||||
title="Headerless window"
|
||||
onClose={() => {}}
|
||||
hideHeader
|
||||
dragHandleSelector=".delegated-drag-handle"
|
||||
defaultSize={{ width: 320, height: 240 }}
|
||||
defaultPosition={{ x: 80, y: 90 }}
|
||||
>
|
||||
<div className="delegated-drag-handle">Task detail header</div>
|
||||
</FloatingWindow>,
|
||||
);
|
||||
|
||||
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("");
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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<string, boolean>([
|
||||
[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 });
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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() {
|
||||
</div>;
|
||||
}
|
||||
|
||||
function FloatingWindowHarness() {
|
||||
return <FloatingWindow
|
||||
windowKey="fn-8605-floating"
|
||||
title="Floating task detail"
|
||||
onClose={() => 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
|
||||
>
|
||||
<div>Floating task detail body</div>
|
||||
</FloatingWindow>;
|
||||
}
|
||||
|
||||
function HeaderlessFloatingWindowHarness() {
|
||||
return <FloatingWindow
|
||||
windowKey="fn-8605-headerless-floating"
|
||||
title="Headerless floating task detail"
|
||||
onClose={() => 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
|
||||
>
|
||||
<div className="fn-8605-delegated-drag-handle">Headerless task detail</div>
|
||||
<div>Floating task detail body</div>
|
||||
</FloatingWindow>;
|
||||
}
|
||||
|
||||
function Fixture() {
|
||||
return <I18nextProvider i18n={i18n}>
|
||||
<ConfirmDialogProvider skipConfirmations>
|
||||
{surface === "task-detail" ? <TaskDetailResizeHarness /> : <NewTaskModal
|
||||
{surface === "floating-window" ? <FloatingWindowHarness /> : surface === "floating-window-headerless" ? <HeaderlessFloatingWindowHarness /> : surface === "task-detail" ? <TaskDetailResizeHarness /> : <NewTaskModal
|
||||
isOpen
|
||||
tasks={[]}
|
||||
onClose={() => undefined}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
packages/dashboard/e2e/__screenshots__/fn-8605/tablet-after.png
Normal file
BIN
packages/dashboard/e2e/__screenshots__/fn-8605/tablet-after.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
packages/dashboard/e2e/__screenshots__/fn-8605/tablet-before.png
Normal file
BIN
packages/dashboard/e2e/__screenshots__/fn-8605/tablet-before.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -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<HTMLElement>("[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);
|
||||
|
||||
Reference in New Issue
Block a user