FN-8602: enable tablet touch resizing for task modals
Enable touch-driven resizing for task modals on tablet viewports. - Add tablet touch resize handles and pointer interactions for task and new-task modals. - Preserve responsive modal sizing while keeping phone layouts fullscreen. - Add unit, browser, and visual regression coverage with updated documentation. Files changed: .changeset/fn-8602-tablet-touch-resize.md | 7 + docs/dashboard-guide.md | 6 +- docs/testing.md | 4 + packages/dashboard/app/components/NewTaskModal.css | 70 ++++++++++ packages/dashboard/app/components/NewTaskModal.tsx | 19 ++- .../dashboard/app/components/TaskDetailModal.css | 12 ++ .../dashboard/app/components/TaskDetailModal.tsx | 7 +- .../app/components/__tests__/NewTaskModal.test.tsx | 4 + ...etailModal.responsive-and-dependencies.test.tsx | 1 + .../hooks/__tests__/useModalResizePersist.test.tsx | 10 +- .../app/hooks/__tests__/useViewportMode.test.ts | 26 +++- .../dashboard/app/hooks/useModalResizePersist.ts | 26 +++- packages/dashboard/app/hooks/useViewportMode.ts | 13 ++ packages/dashboard/app/styles.css | 16 +++ .../app/task-modal-touch-resize-e2e-fixture.html | 5 + .../app/task-modal-touch-resize-e2e-fixture.tsx | 60 ++++++++ .../__screenshots__/fn-8602/phone-fullscreen.png | Bin 0 -> 43987 bytes .../e2e/__screenshots__/fn-8602/tablet-after.png | Bin 0 -> 52549 bytes .../e2e/__screenshots__/fn-8602/tablet-before.png | Bin 0 -> 11500 bytes .../task-modal-touch-resize-browser.test.ts | 154 +++++++++++++++++++++ 20 files changed, 424 insertions(+), 16 deletions(-) Fusion-Task-Id: FN-8602 Fusion-Task-Lineage: f07ce22f-f529-4be3-ba1b-04063154a1b0 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8602-tablet-touch-resize.md
Normal file
7
.changeset/fn-8602-tablet-touch-resize.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Make task modal resize grips reliably usable on touch tablets.
|
||||
category: fix
|
||||
dev: Adds tablet-only touch targets and Chromium CDP hit-testing coverage.
|
||||
@@ -89,7 +89,7 @@ Movable dashboard pop-outs remember their last desktop location and size, while
|
||||
<!-- FNXC:TaskModalResizeDocs 2026-08-07-00:00: Known touch tablets at the 768px CSS boundary use the shared physical-screen-aware viewport classification, so documentation must distinguish their resize contract from true phones that share the CSS media query. -->
|
||||
### Task modal resizing on tablets
|
||||
|
||||
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. Their geometry stays within the viewport and is restored from browser storage on later tablet or desktop opens. True phones and narrow folded panes remain full-screen sheets without active resize controls so keyboard and safe-area behavior is unchanged.
|
||||
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.
|
||||
|
||||
## Mobile/PWA app icons
|
||||
|
||||
@@ -2293,3 +2293,7 @@ Dictation inserts a live partial transcript at the current caret (or replaces th
|
||||
### Conversation tags
|
||||
|
||||
Direct conversations can be organized with reusable tags. Open a conversation's **More** menu to create a tag or toggle its assignments; a conversation can have multiple tags. Use the tag selector beside conversation search to filter pinned and recent conversations without affecting text search. Tags are project-scoped, and deleting a tag only removes its assignments—it never deletes conversations or messages. Chat Rooms do not use conversation tags.
|
||||
|
||||
### Tablet touch modal resize
|
||||
|
||||
Task Detail and New Task retain their desktop resize chrome, but tablet-class touch viewports expose a 44px `data-resize-hit-target` around resize controls. The target is enabled only by the shared tablet-touch viewport classifier; true-phone sheets and desktop coarse-pointer devices do not expose it.
|
||||
|
||||
@@ -523,3 +523,7 @@ Use the exact heading `## Symptom Verification` and include all three required c
|
||||
- [ ] **Assertion it is gone** — final verification reproduces the original failure condition and asserts it no longer occurs via a real automated test.
|
||||
|
||||
Symptom-based acceptance is mandatory for bug fixes: reproduce the original failure, prove it is gone, and keep the invariant covered across the `## Surface Enumeration` checklist. Green build/tests alone are insufficient when they do not exercise the reported symptom.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -131,6 +131,76 @@ visible tokenized focus ring so keyboard users can discover the same controls to
|
||||
.new-task-resize-handle--se { bottom: 0; right: 0; cursor: nwse-resize; }
|
||||
.new-task-resize-handle--sw { bottom: 0; left: 0; cursor: nesw-resize; }
|
||||
|
||||
/*
|
||||
FNXC:TaskModalResize 2026-07-26-10:40:
|
||||
Finger drags need a 44px hit area, unlike the mouse-sized painted edge/corner controls.
|
||||
These targets expand outward from the floating panel so they cannot cover its footer or
|
||||
scrollable body. The runtime tablet-touch class is required: true-phone sheets and desktop
|
||||
coarse-pointer devices must never expose an invisible resize target.
|
||||
*/
|
||||
.task-modal--touch-resize .new-task-resize-handle--touch-target {
|
||||
z-index: 3;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--n,
|
||||
.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--s {
|
||||
left: var(--modal-resize-touch-target);
|
||||
right: var(--modal-resize-touch-target);
|
||||
height: var(--modal-resize-touch-target);
|
||||
}
|
||||
|
||||
.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--n {
|
||||
top: calc((var(--modal-resize-touch-target) - var(--space-sm)) * -1);
|
||||
}
|
||||
|
||||
.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--s {
|
||||
bottom: calc((var(--modal-resize-touch-target) - var(--space-sm)) * -1);
|
||||
}
|
||||
|
||||
.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--e,
|
||||
.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--w {
|
||||
top: var(--modal-resize-touch-target);
|
||||
bottom: var(--modal-resize-touch-target);
|
||||
width: var(--modal-resize-touch-target);
|
||||
}
|
||||
|
||||
.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--e {
|
||||
right: calc((var(--modal-resize-touch-target) - var(--space-sm)) * -1);
|
||||
}
|
||||
|
||||
.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--w {
|
||||
left: calc((var(--modal-resize-touch-target) - var(--space-sm)) * -1);
|
||||
}
|
||||
|
||||
.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--ne,
|
||||
.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--nw,
|
||||
.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--se,
|
||||
.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-resize-handle--sw {
|
||||
width: var(--modal-resize-touch-target);
|
||||
height: var(--modal-resize-touch-target);
|
||||
}
|
||||
|
||||
.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-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);
|
||||
}
|
||||
|
||||
.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-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);
|
||||
}
|
||||
|
||||
.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-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);
|
||||
}
|
||||
|
||||
.task-modal--touch-resize .new-task-resize-handle--touch-target.new-task-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);
|
||||
}
|
||||
|
||||
.new-task-modal .modal-body {
|
||||
padding: var(--space-xl);
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -28,7 +28,7 @@ import { useConfirm } from "../hooks/useConfirm";
|
||||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||
import { useNodes } from "../hooks/useNodes";
|
||||
import { useViewportMode } from "../hooks/useViewportMode";
|
||||
import { isTabletTouchViewport, useViewportMode } from "../hooks/useViewportMode";
|
||||
import { useAgentsMapCache } from "../hooks/useAgentsMapCache";
|
||||
import { nextFloatingZ, currentFloatingZ } from "./floatingWindowStack";
|
||||
|
||||
@@ -412,6 +412,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
const { t } = useTranslation("app");
|
||||
const { confirm } = useConfirm();
|
||||
const viewportMode = useViewportMode();
|
||||
const isTabletTouchResize = isTabletTouchViewport(viewportMode);
|
||||
useMobileScrollLock(isOpen);
|
||||
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({
|
||||
enabled: viewportMode === "mobile",
|
||||
@@ -521,6 +522,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
if (moveEvent.pointerId !== pointerId) return;
|
||||
moveEvent.preventDefault();
|
||||
const dx = moveEvent.clientX - startX;
|
||||
const dy = moveEvent.clientY - startY;
|
||||
const nextSize = clampFloatSize({
|
||||
@@ -546,7 +548,15 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
captureTarget.removeEventListener("pointerup", handlePointerUp);
|
||||
captureTarget.removeEventListener("pointercancel", handlePointerUp);
|
||||
};
|
||||
function handlePointerUp() {
|
||||
/*
|
||||
FNXC:TaskModalResize 2026-07-26-10:51:
|
||||
A tablet finger resize owns exactly the pointer that started it. Ignore another
|
||||
finger's terminal event so it cannot release capture or persist partial geometry;
|
||||
this preserves the established floating-window gesture isolation.
|
||||
*/
|
||||
function handlePointerUp(upEvent: PointerEvent) {
|
||||
if (upEvent.pointerId !== pointerId) return;
|
||||
upEvent.preventDefault();
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
persistSize(latestSize);
|
||||
persistPosition(latestPosition, latestSize);
|
||||
@@ -1206,7 +1216,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
style={isFloating ? { zIndex } : undefined}
|
||||
>
|
||||
<div
|
||||
className={`modal modal-lg new-task-modal${viewportMode === "tablet" ? " task-modal--tablet" : ""}${isFloating ? " new-task-modal--floating" : ""}`}
|
||||
className={`modal modal-lg new-task-modal${viewportMode === "tablet" ? " task-modal--tablet" : ""}${isTabletTouchResize ? " task-modal--touch-resize" : ""}${isFloating ? " new-task-modal--floating" : ""}`}
|
||||
style={panelStyle}
|
||||
onPointerDownCapture={isFloating ? bringToFront : undefined}
|
||||
onFocusCapture={isFloating ? bringToFront : undefined}
|
||||
@@ -1214,8 +1224,9 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
{isFloating && NEW_TASK_RESIZE_DIRECTIONS.map((direction) => (
|
||||
<div
|
||||
key={direction}
|
||||
className={`new-task-resize-handle new-task-resize-handle--${direction}`}
|
||||
className={`new-task-resize-handle new-task-resize-handle--${direction}${isTabletTouchResize ? " new-task-resize-handle--touch-target" : ""}`}
|
||||
data-testid={`new-task-resize-${direction}`}
|
||||
{...(isTabletTouchResize ? { "data-resize-hit-target": "true" } : {})}
|
||||
role="separator"
|
||||
aria-orientation={direction === "n" || direction === "s" ? "horizontal" : "vertical"}
|
||||
aria-valuemin={direction === "n" || direction === "s" ? NEW_TASK_MIN_HEIGHT : NEW_TASK_MIN_WIDTH}
|
||||
|
||||
@@ -18,6 +18,17 @@
|
||||
resize: both;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:TaskModalResize 2026-07-26-11:06:
|
||||
The tablet-only grip expands outside Task Detail's painted corner. Its default
|
||||
overflow clipping would make the 44px finger target visible in layout but
|
||||
unreachable by browser hit testing, so only the explicit touch-resize surface
|
||||
permits the target outside the panel; true-phone sheets retain clipped content.
|
||||
*/
|
||||
.modal.task-detail-modal.task-modal--touch-resize {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:TaskDetail 2026-06-22-20:00:
|
||||
The gray top header band (task id + column badge) was over-padded. Trim its vertical padding for a more compact band, scoped to the task-detail header so the shared global .modal-header (used by other modals) is unaffected. Keep horizontal padding from --modal-padding; only the block padding shrinks.
|
||||
@@ -1816,6 +1827,7 @@ FN-6500 fixes a tablet regression from FN-5599: the task-detail overlay offset a
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
.detail-body--chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pencil, Bot, X, ChevronDown, ChevronRight, GitBranch, ArrowLeft, Zap, Loader2, AlertTriangle, Sparkles, Maximize2, Minimize2, Send, Square, Info, Paperclip, Eye, EyeOff } from "lucide-react";
|
||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||
import { useViewportMode } from "../hooks/useViewportMode";
|
||||
import { isTabletTouchViewport, useViewportMode } from "../hooks/useViewportMode";
|
||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||
import { useColumnLabel } from "../i18n/labels";
|
||||
@@ -6587,7 +6587,8 @@ export function TaskDetailContent({
|
||||
export function TaskDetailModal({ onClose, ...props }: TaskDetailModalProps) {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const viewportMode = useViewportMode();
|
||||
useModalResizePersist(modalRef, true, "task-detail-modal-size");
|
||||
const isTabletTouchResize = isTabletTouchViewport(viewportMode);
|
||||
useModalResizePersist(modalRef, true, "task-detail-modal-size", { touchTargets: isTabletTouchResize });
|
||||
useMobileScrollLock(true);
|
||||
const overlayDismissProps = useOverlayDismiss(onClose);
|
||||
/*
|
||||
@@ -6614,7 +6615,7 @@ export function TaskDetailModal({ onClose, ...props }: TaskDetailModalProps) {
|
||||
aria-modal="true"
|
||||
>
|
||||
<div
|
||||
className={`modal modal-lg task-detail-modal${isTabletTaskModal ? " task-modal--tablet" : ""}${isMobileTransition ? " task-detail-modal--mobile-transition" : ""}`}
|
||||
className={`modal modal-lg task-detail-modal${isTabletTaskModal ? " task-modal--tablet" : ""}${isTabletTouchResize ? " task-modal--touch-resize" : ""}${isMobileTransition ? " task-detail-modal--mobile-transition" : ""}`}
|
||||
ref={modalRef}
|
||||
>
|
||||
<TaskDetailContent
|
||||
|
||||
@@ -88,6 +88,7 @@ vi.mock("../../hooks/useViewportMode", () => ({
|
||||
isShortViewport: () => false,
|
||||
getViewportMode: () => mockViewportMode,
|
||||
isMobileViewport: () => mockViewportMode === "mobile",
|
||||
isTabletTouchViewport: () => mockViewportMode === "tablet",
|
||||
useViewportMode: () => mockViewportMode,
|
||||
}));
|
||||
|
||||
@@ -2193,6 +2194,7 @@ describe("NewTaskModal", () => {
|
||||
expect(screen.getByTestId(`new-task-resize-${dir}`)).toHaveAttribute("role", "separator");
|
||||
expect(screen.getByTestId(`new-task-resize-${dir}`)).toHaveAttribute("aria-label", "Resize new task window");
|
||||
expect(screen.getByTestId(`new-task-resize-${dir}`)).toHaveAttribute("tabindex", "0");
|
||||
expect(screen.getByTestId(`new-task-resize-${dir}`)).toHaveAttribute("data-resize-hit-target", "true");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2253,6 +2255,8 @@ describe("NewTaskModal", () => {
|
||||
|
||||
fireEvent.pointerDown(handle, { pointerId: 4, clientX: 100, clientY: 100, pointerType: "touch" });
|
||||
fireEvent.pointerMove(handle, { pointerId: 4, clientX: 140, clientY: 130, pointerType: "touch" });
|
||||
fireEvent.pointerUp(handle, { pointerId: 9, clientX: 140, clientY: 130, pointerType: "touch" });
|
||||
expect(document.body.style.userSelect).toBe("none");
|
||||
fireEvent.pointerUp(handle, { pointerId: 4, clientX: 140, clientY: 130, pointerType: "touch" });
|
||||
|
||||
expect(Number.parseFloat(panel.style.width)).toBeGreaterThan(initialWidth);
|
||||
|
||||
@@ -874,6 +874,7 @@ describe("TaskDetailModal", () => {
|
||||
const grip = modal?.querySelector(".modal-resize-grip") as HTMLElement;
|
||||
expect(grip).toHaveAttribute("aria-label", "Resize modal from bottom-right corner");
|
||||
expect(grip).toHaveAttribute("tabindex", "0");
|
||||
expect(grip).toHaveAttribute("data-resize-hit-target", "true");
|
||||
|
||||
// FNXC:TaskModalResize 2026-07-24-19:20: The 768px tablet recovery must
|
||||
// remain keyboard discoverable, not merely restore a touch-only grip.
|
||||
|
||||
@@ -99,14 +99,16 @@ function Harness({
|
||||
initialWidth,
|
||||
isOpen = true,
|
||||
storageKey = STORAGE_KEY,
|
||||
touchTargets = false,
|
||||
}: {
|
||||
initialHeight?: string;
|
||||
initialWidth?: string;
|
||||
isOpen?: boolean;
|
||||
storageKey?: string;
|
||||
touchTargets?: boolean;
|
||||
}) {
|
||||
const modalRef = useRef<HTMLDivElement | null>(null);
|
||||
useModalResizePersist(modalRef, isOpen, storageKey);
|
||||
useModalResizePersist(modalRef, isOpen, storageKey, { touchTargets });
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -134,9 +136,9 @@ describe("useModalResizePersist", () => {
|
||||
document.body.style.userSelect = "";
|
||||
});
|
||||
|
||||
it("injects a touch-capable resize grip on tablet and persists dragged size", () => {
|
||||
it("injects a tablet touch hit target and persists a touch drag", () => {
|
||||
setViewport(900);
|
||||
render(<Harness />);
|
||||
render(<Harness touchTargets />);
|
||||
|
||||
const modal = screen.getByTestId("modal");
|
||||
installModalGeometry(modal);
|
||||
@@ -146,6 +148,8 @@ describe("useModalResizePersist", () => {
|
||||
|
||||
expect(grip).toHaveAttribute("role", "separator");
|
||||
expect(grip).toHaveAttribute("aria-label", "Resize modal from bottom-right corner");
|
||||
expect(grip).toHaveAttribute("data-resize-hit-target", "true");
|
||||
expect(grip).toHaveClass("modal-resize-grip--touch-target");
|
||||
|
||||
dispatchPointerEvent(grip, "pointerdown", { clientX: 10, clientY: 20, pointerType: "touch" });
|
||||
dispatchPointerEvent(document, "pointermove", { clientX: 70, clientY: 65, pointerType: "touch" });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { getViewportMode, isFullScreenSheetViewport, isMobileViewport, MOBILE_MEDIA_QUERY, useViewportMode } from "../useViewportMode";
|
||||
import { getViewportMode, isFullScreenSheetViewport, isMobileViewport, isTabletTouchViewport, MOBILE_MEDIA_QUERY, useViewportMode } from "../useViewportMode";
|
||||
|
||||
const TABLET_MEDIA_QUERY = "(min-width: 769px) and (max-width: 1024px)";
|
||||
const MOBILE_WIDTH_MEDIA_QUERY = "(max-width: 768px)";
|
||||
@@ -242,6 +242,30 @@ describe("useViewportMode", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("enables the resize touch target only for tablet-class touch viewports", () => {
|
||||
const originalMaxTouchPoints = Object.getOwnPropertyDescriptor(navigator, "maxTouchPoints");
|
||||
Object.defineProperty(navigator, "maxTouchPoints", { configurable: true, value: 1 });
|
||||
try {
|
||||
stubScreen(768, 1024);
|
||||
installViewportMedia({ width: true, height: false, tablet: false });
|
||||
expect(isTabletTouchViewport()).toBe(true);
|
||||
|
||||
stubScreen(1024, 768);
|
||||
installViewportMedia({ width: false, height: false, tablet: true });
|
||||
expect(isTabletTouchViewport()).toBe(true);
|
||||
|
||||
stubScreen(1920, 1080);
|
||||
installViewportMedia({ width: false, height: false, tablet: false });
|
||||
expect(isTabletTouchViewport()).toBe(false);
|
||||
|
||||
stubScreen(390, 844);
|
||||
installViewportMedia({ width: true, height: false, tablet: false });
|
||||
expect(isTabletTouchViewport()).toBe(false);
|
||||
} finally {
|
||||
if (originalMaxTouchPoints) Object.defineProperty(navigator, "maxTouchPoints", originalMaxTouchPoints);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a touch tablet at the 768px boundary out of the phone presentation", () => {
|
||||
const originalMaxTouchPoints = Object.getOwnPropertyDescriptor(navigator, "maxTouchPoints");
|
||||
stubScreen(768, 1024);
|
||||
|
||||
@@ -10,6 +10,11 @@ interface PersistedSize {
|
||||
const RESIZE_GRIP_CLASS = "modal-resize-grip";
|
||||
const RESIZE_GRIP_LABEL = "Resize modal from bottom-right corner";
|
||||
|
||||
interface ModalResizePersistOptions {
|
||||
/** Enable the explicit 44px hit target on the tablet-touch task-detail surface. */
|
||||
touchTargets?: boolean;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:TaskModalResize 2026-07-24-19:20:
|
||||
The shared Task Detail grip must offer the same keyboard discovery as floating task windows.
|
||||
@@ -51,11 +56,13 @@ function readPersistableSize(node: HTMLElement): PersistedSize {
|
||||
* @param ref ref to the resizable modal element
|
||||
* @param isOpen the modal's open flag — observation only runs while true
|
||||
* @param storageKey localStorage key, must be stable + unique per modal
|
||||
* @param options tablet-only touch-target opt-in; other shared modal consumers retain desktop geometry
|
||||
*/
|
||||
export function useModalResizePersist(
|
||||
ref: RefObject<HTMLElement | null>,
|
||||
isOpen: boolean,
|
||||
storageKey: string,
|
||||
options: ModalResizePersistOptions = {},
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
@@ -104,7 +111,16 @@ export function useModalResizePersist(
|
||||
};
|
||||
|
||||
const grip = document.createElement("div");
|
||||
grip.className = RESIZE_GRIP_CLASS;
|
||||
grip.className = `${RESIZE_GRIP_CLASS}${options.touchTargets ? " modal-resize-grip--touch-target" : ""}`;
|
||||
if (options.touchTargets) {
|
||||
/*
|
||||
FNXC:TaskModalResize 2026-07-26-10:40:
|
||||
Keep the painted corner grip mouse-sized while exposing a separately queryable
|
||||
touch hit target. This lets tablet touch input land outside the legacy visual
|
||||
corner without enlarging borders, footer chrome, or content hit areas.
|
||||
*/
|
||||
grip.dataset.resizeHitTarget = "true";
|
||||
}
|
||||
grip.setAttribute("role", "separator");
|
||||
grip.setAttribute("aria-label", RESIZE_GRIP_LABEL);
|
||||
grip.setAttribute("aria-orientation", "vertical");
|
||||
@@ -165,9 +181,10 @@ export function useModalResizePersist(
|
||||
document.body.style.userSelect = "none";
|
||||
|
||||
const onPointerMove = (moveEvent: PointerEvent) => {
|
||||
if (moveEvent.pointerId !== event.pointerId) return;
|
||||
moveEvent.preventDefault();
|
||||
const nextWidth = startWidth + moveEvent.clientX - startX;
|
||||
const nextHeight = startHeight + moveEvent.clientY - startY;
|
||||
const nextWidth = Math.min(window.innerWidth, startWidth + moveEvent.clientX - startX);
|
||||
const nextHeight = Math.min(window.innerHeight, startHeight + moveEvent.clientY - startY);
|
||||
if (nextWidth > 0) node.style.width = `${nextWidth}px`;
|
||||
if (nextHeight > 0) node.style.height = `${nextHeight}px`;
|
||||
syncGripAria();
|
||||
@@ -175,6 +192,7 @@ export function useModalResizePersist(
|
||||
};
|
||||
|
||||
const endDrag = (upEvent: PointerEvent) => {
|
||||
if (upEvent.pointerId !== event.pointerId) return;
|
||||
if (typeof grip.releasePointerCapture === "function") {
|
||||
grip.releasePointerCapture(upEvent.pointerId);
|
||||
}
|
||||
@@ -227,5 +245,5 @@ export function useModalResizePersist(
|
||||
observer?.disconnect();
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
};
|
||||
}, [ref, isOpen, storageKey]);
|
||||
}, [ref, isOpen, storageKey, options.touchTargets]);
|
||||
}
|
||||
|
||||
@@ -134,6 +134,19 @@ export function getViewportMode(): ViewportMode {
|
||||
return "desktop";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a viewport is the tablet-only touch-resize surface.
|
||||
*
|
||||
* FNXC:TaskModalResize 2026-07-26-10:40:
|
||||
* Tablet resize controls need a finger-sized target, but `(pointer: coarse)` alone
|
||||
* would also enlarge controls on desktop hybrids and true phones. Compose the existing
|
||||
* physical-screen-aware tablet classifier with touch capability instead; phones retain
|
||||
* full-screen sheets and desktop coarse-pointer devices retain mouse-sized chrome.
|
||||
*/
|
||||
export function isTabletTouchViewport(mode = getViewportMode()): boolean {
|
||||
return mode === "tablet" && hasTouchScreen() && !isPhoneClassScreen();
|
||||
}
|
||||
|
||||
export function useViewportMode(): ViewportMode {
|
||||
const [mode, setMode] = useState<ViewportMode>(getViewportMode);
|
||||
|
||||
|
||||
@@ -152,6 +152,14 @@ html {
|
||||
44px accessibility floor without embedding a raw dimension in component-specific rules.
|
||||
*/
|
||||
--touch-target-min-size: 44px;
|
||||
/*
|
||||
FNXC:TaskModalResize 2026-07-26-10:40:
|
||||
Task-modal resize targets use the accessibility floor as an explicit hit-area
|
||||
contract. The painted grip remains compact; only the tablet-class touch guard
|
||||
opts into this token so phones and desktop coarse-pointer layouts do not gain
|
||||
invisible resize chrome.
|
||||
*/
|
||||
--modal-resize-touch-target: var(--touch-target-min-size);
|
||||
|
||||
/*
|
||||
FNXC:QuickAddActionRow 2026-07-16-14:00:
|
||||
@@ -1344,6 +1352,14 @@ Modal and dock pop-out surfaces should not darken or blur the app behind them. K
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.modal-resize-grip--touch-target {
|
||||
width: var(--modal-resize-touch-target);
|
||||
height: var(--modal-resize-touch-target);
|
||||
right: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1);
|
||||
bottom: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.modal-resize-grip::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Task modal touch resize E2E</title><style>html, body, #root { min-height: 100%; margin: 0; }</style></head>
|
||||
<body><div id="root"></div><script type="module" src="/app/task-modal-touch-resize-e2e-fixture.tsx"></script></body>
|
||||
</html>
|
||||
@@ -0,0 +1,60 @@
|
||||
import React, { useRef } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import i18n from "i18next";
|
||||
import { I18nextProvider, initReactI18next } from "react-i18next";
|
||||
import "./styles.css";
|
||||
import "./components/TaskDetailModal.css";
|
||||
import { useModalResizePersist } from "./hooks/useModalResizePersist";
|
||||
import { isTabletTouchViewport, useViewportMode } from "./hooks/useViewportMode";
|
||||
import { NewTaskModal } from "./components/NewTaskModal";
|
||||
import { ConfirmDialogProvider } from "./hooks/useConfirm";
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const surface = params.get("surface") ?? "new-task";
|
||||
if (params.has("reset")) localStorage.clear();
|
||||
|
||||
void i18n.use(initReactI18next).init({
|
||||
lng: "en",
|
||||
fallbackLng: "en",
|
||||
resources: { en: { app: {} } },
|
||||
interpolation: { escapeValue: false },
|
||||
});
|
||||
|
||||
// Browser fixtures provide the production form's minimal typed API payloads. The resize assertions
|
||||
// exercise NewTaskModal itself rather than an API-dependent form failure.
|
||||
window.fetch = async (input) => {
|
||||
const url = String(input);
|
||||
const payload = url.includes("/models")
|
||||
? { models: [], favoriteProviders: [], favoriteModels: [] }
|
||||
: url.includes("/settings") ? {}
|
||||
: [];
|
||||
return new Response(JSON.stringify(payload), { headers: { "content-type": "application/json" } });
|
||||
};
|
||||
|
||||
function TaskDetailResizeHarness() {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const viewportMode = useViewportMode();
|
||||
const touchTargets = isTabletTouchViewport(viewportMode);
|
||||
useModalResizePersist(ref, true, "task-detail-modal-size", { touchTargets });
|
||||
return <div className="modal-overlay open" style={{ paddingTop: "80px" }}>
|
||||
<div ref={ref} className={`modal modal-lg task-detail-modal${viewportMode === "tablet" ? " task-modal--tablet" : ""}${touchTargets ? " task-modal--touch-resize" : ""}`} data-testid="task-detail-modal" style={{ width: "560px", height: "480px" }}>
|
||||
<div className="modal-header">Task detail</div><div className="modal-body">Task detail body</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Fixture() {
|
||||
return <I18nextProvider i18n={i18n}>
|
||||
<ConfirmDialogProvider skipConfirmations>
|
||||
{surface === "task-detail" ? <TaskDetailResizeHarness /> : <NewTaskModal
|
||||
isOpen
|
||||
tasks={[]}
|
||||
onClose={() => undefined}
|
||||
onCreateTask={async () => ({ id: "FN-E2E" }) as never}
|
||||
addToast={() => undefined}
|
||||
/>}
|
||||
</ConfirmDialogProvider>
|
||||
</I18nextProvider>;
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<Fixture />);
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
BIN
packages/dashboard/e2e/__screenshots__/fn-8602/tablet-after.png
Normal file
BIN
packages/dashboard/e2e/__screenshots__/fn-8602/tablet-after.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
BIN
packages/dashboard/e2e/__screenshots__/fn-8602/tablet-before.png
Normal file
BIN
packages/dashboard/e2e/__screenshots__/fn-8602/tablet-before.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,154 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { createServer, type ViteDevServer } from "vite";
|
||||
import { createRequire } from "node:module";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const requireFromEngine = createRequire(new URL("../../../engine/package.json", import.meta.url));
|
||||
const { chromium } = requireFromEngine("playwright-core") as { chromium: { launch(options: { executablePath: string; headless: boolean; args?: string[] }): Promise<Browser> } };
|
||||
type Browser = { newPage(options: { viewport: { width: number; height: number } }): Promise<Page>; close(): Promise<void> };
|
||||
type Page = { goto(url: string): Promise<unknown>; evaluate<T, Arg = undefined>(fn: (arg: Arg) => T, arg?: Arg): Promise<T>; locator(selector: string): Locator; waitForTimeout(ms: number): Promise<void>; screenshot(options: { path: string }): Promise<void>; close(): Promise<void>; context(): { newCDPSession(page: Page): Promise<Cdp> }; on(event: "console" | "pageerror", listener: (message: { text?(): string; message?: string }) => void): void };
|
||||
type Locator = { boundingBox(): Promise<{ x: number; y: number; width: number; height: number } | null> };
|
||||
type Cdp = { send(method: string, params: Record<string, unknown>): Promise<unknown> };
|
||||
type Point = { x: number; y: number };
|
||||
type Rect = { x: number; y: number; width: number; height: number };
|
||||
|
||||
const browserCandidates = process.platform === "darwin"
|
||||
? ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium"]
|
||||
: ["/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");
|
||||
|
||||
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 }] });
|
||||
for (const fraction of [0.25, 0.5, 0.75, 1]) {
|
||||
await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x: point.x + delta.x * fraction, y: point.y + delta.y * fraction, id: 1 }] });
|
||||
}
|
||||
await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
|
||||
}
|
||||
|
||||
async function setTabletMetrics(cdp: Cdp, width: number, height: number) {
|
||||
await cdp.send("Emulation.setDeviceMetricsOverride", {
|
||||
width,
|
||||
height,
|
||||
screenWidth: width,
|
||||
screenHeight: height,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: false,
|
||||
});
|
||||
await cdp.send("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 1 });
|
||||
}
|
||||
|
||||
async function rect(page: Page, selector: string): Promise<Rect> {
|
||||
return page.evaluate((target) => {
|
||||
const panel = document.querySelector<HTMLElement>(target);
|
||||
if (!panel) throw new Error(`Missing ${target}`);
|
||||
const { x, y, width, height } = panel.getBoundingClientRect();
|
||||
return { x, y, width, height };
|
||||
}, selector);
|
||||
}
|
||||
|
||||
async function targetCenter(page: Page, selector: string): Promise<Point> {
|
||||
const box = await page.locator(selector).boundingBox();
|
||||
if (!box) throw new Error(`Missing resize target ${selector}`);
|
||||
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:TaskModalResize 2026-08-12-15:12:
|
||||
Browser CDP gestures are required because jsdom cannot resolve CSS hit targets. This fixture mounts
|
||||
both production resize paths and sends CSS-pixel touch input through Chromium so elementFromPoint,
|
||||
pointer capture, persistence, and header-drag isolation use the same browser input path.
|
||||
*/
|
||||
describe.runIf(executablePath)("Task modal tablet touch resize browser regression", () => {
|
||||
let server: ViteDevServer; let browser: Browser; let baseUrl = "";
|
||||
beforeAll(async () => {
|
||||
server = await createServer({ root: process.cwd(), server: { host: "127.0.0.1", port: 0, watch: null }, logLevel: "error" });
|
||||
await server.listen(); baseUrl = server.resolvedUrls?.local[0] ?? "";
|
||||
browser = await chromium.launch({ executablePath, headless: true, ...(process.env.CI ? { args: ["--no-sandbox", "--disable-dev-shm-usage"] } : {}) });
|
||||
}, 30_000);
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
await server?.watcher.close();
|
||||
server?.ws.close();
|
||||
server?.httpServer?.closeAllConnections?.();
|
||||
await new Promise<void>((resolve) => server?.httpServer?.close(() => resolve()));
|
||||
await server?.pluginContainer.close();
|
||||
}, 15_000);
|
||||
|
||||
for (const [width, height] of [[768, 1024], [820, 1180]] as const) {
|
||||
it(`hits and resizes Task Detail and New Task at the ${width}px tablet boundary with CDP touch`, async () => {
|
||||
const page = await browser.newPage({ viewport: { width, height } });
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await setTabletMetrics(cdp, width, height);
|
||||
page.on("console", (message) => console.log(`[task-modal-touch-resize] ${message.text?.() ?? ""}`));
|
||||
page.on("pageerror", (message) => console.error(`[task-modal-touch-resize] ${message.message ?? ""}`));
|
||||
await page.goto(`${baseUrl}app/task-modal-touch-resize-e2e-fixture.html?surface=task-detail&reset=1`);
|
||||
await page.waitForTimeout(350);
|
||||
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(1);
|
||||
await mkdir(screenshots, { recursive: true });
|
||||
if (width === 820) await page.screenshot({ path: path.join(screenshots, "tablet-before.png") });
|
||||
|
||||
const detailSelector = "[data-testid='task-detail-modal'] .modal-resize-grip";
|
||||
const detailPoint = await targetCenter(page, detailSelector);
|
||||
expect(await page.evaluate((point) => document.elementFromPoint(point.x, point.y)?.getAttribute("data-resize-hit-target"), detailPoint)).toBe("true");
|
||||
const detailBefore = await rect(page, "[data-testid='task-detail-modal']");
|
||||
await touchDrag(cdp, detailPoint);
|
||||
await page.waitForTimeout(250);
|
||||
const detailAfter = await rect(page, "[data-testid='task-detail-modal']");
|
||||
expect(detailAfter.width).toBeGreaterThan(detailBefore.width);
|
||||
expect(detailAfter.height).toBeGreaterThan(detailBefore.height);
|
||||
expect(detailAfter.width).toBeLessThanOrEqual(width);
|
||||
expect(detailAfter.height).toBeLessThanOrEqual(height);
|
||||
expect(await page.evaluate(() => localStorage.getItem("task-detail-modal-size"))).not.toBeNull();
|
||||
|
||||
await page.goto(`${baseUrl}app/task-modal-touch-resize-e2e-fixture.html?surface=new-task`);
|
||||
await page.waitForTimeout(350);
|
||||
expect(await page.evaluate(() => document.querySelectorAll("[data-resize-hit-target='true']").length)).toBe(8);
|
||||
const newTaskPanel = ".new-task-modal";
|
||||
const headerPoint = await targetCenter(page, "[data-testid='new-task-drag-handle']");
|
||||
const newTaskBeforeHeaderDrag = await rect(page, newTaskPanel);
|
||||
await touchDrag(cdp, headerPoint, { x: 32, y: 28 });
|
||||
await page.waitForTimeout(100);
|
||||
const newTaskAfterHeaderDrag = await rect(page, newTaskPanel);
|
||||
expect(newTaskAfterHeaderDrag.x).not.toBe(newTaskBeforeHeaderDrag.x);
|
||||
expect(newTaskAfterHeaderDrag.y).not.toBe(newTaskBeforeHeaderDrag.y);
|
||||
expect(newTaskAfterHeaderDrag.width).toBe(newTaskBeforeHeaderDrag.width);
|
||||
expect(newTaskAfterHeaderDrag.height).toBe(newTaskBeforeHeaderDrag.height);
|
||||
|
||||
const newTaskTarget = "[data-testid='new-task-resize-se']";
|
||||
const newTaskPoint = await targetCenter(page, newTaskTarget);
|
||||
expect(await page.evaluate((point) => document.elementFromPoint(point.x, point.y)?.getAttribute("data-resize-hit-target"), newTaskPoint)).toBe("true");
|
||||
const newTaskBeforeResize = await rect(page, newTaskPanel);
|
||||
await touchDrag(cdp, newTaskPoint);
|
||||
await page.waitForTimeout(100);
|
||||
const newTaskAfterResize = await rect(page, newTaskPanel);
|
||||
expect(newTaskAfterResize.width).toBeGreaterThan(newTaskBeforeResize.width);
|
||||
expect(newTaskAfterResize.height).toBeGreaterThan(newTaskBeforeResize.height);
|
||||
expect(newTaskAfterResize.width).toBeLessThanOrEqual(width - 32);
|
||||
expect(newTaskAfterResize.height).toBeLessThanOrEqual(height - 32);
|
||||
expect(await page.evaluate(() => localStorage.getItem("fusion:new-task-modal-size"))).not.toBeNull();
|
||||
expect(await page.evaluate(() => localStorage.getItem("fusion:new-task-modal-position"))).not.toBeNull();
|
||||
if (width === 820) await page.screenshot({ path: path.join(screenshots, "tablet-after.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);
|
||||
await cdp.send("Emulation.setDeviceMetricsOverride", { width: 390, height: 844, screenWidth: 390, screenHeight: 844, deviceScaleFactor: 1, mobile: true });
|
||||
await cdp.send("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 1 });
|
||||
await page.goto(`${baseUrl}app/task-modal-touch-resize-e2e-fixture.html?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>(".new-task-modal");
|
||||
return panel ? panel.getBoundingClientRect().height >= window.innerHeight * 0.9 : false;
|
||||
})).toBe(true);
|
||||
await page.screenshot({ path: path.join(screenshots, "phone-fullscreen.png") });
|
||||
await page.close();
|
||||
}, 30_000);
|
||||
});
|
||||
Reference in New Issue
Block a user