FN-8565: restore tablet task modal resizing

Restore resizable Task Detail and New Task windows on 768px touch tablets.

- Classify known touch tablets separately from phone sheets
- Restore bounded modal geometry and resize grips with keyboard support
- Add regression coverage, documentation, and a patch changeset

Files changed:
 .changeset/fn-8565-tablet-task-modal-resize.md     |  7 +++
 docs/dashboard-guide.md                            |  5 ++
 .../task-detail-modal-tablet-width.test.ts         | 15 +++++
 packages/dashboard/app/components/NewTaskModal.css | 18 +++++-
 packages/dashboard/app/components/NewTaskModal.tsx | 44 +++++++++++++-
 .../dashboard/app/components/TaskDetailModal.css   | 30 ++++++++++
 .../dashboard/app/components/TaskDetailModal.tsx   | 40 +++++--------
 .../app/components/__tests__/NewTaskModal.test.tsx | 68 +++++++++++++++++++++-
 ...etailModal.responsive-and-dependencies.test.tsx | 51 ++++++++++++++++
 .../hooks/__tests__/useModalResizePersist.test.tsx | 53 ++++++++++++++++-
 .../dashboard/app/hooks/useModalResizePersist.ts   | 58 +++++++++++++++---
 11 files changed, 352 insertions(+), 37 deletions(-)

Fusion-Task-Id: FN-8565

Fusion-Task-Lineage: 7d9bbeb9-0e6a-4ad9-8b4d-e9f3d8aef650

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-24 19:20:16 -07:00
parent e3dba364d1
commit 4b4df1ff30
11 changed files with 352 additions and 37 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Restore touch resizing for Task Detail and New Task on tablets.
category: fix
dev: Known touch tablets at the 768px boundary now retain floating modal geometry and resize controls.

View File

@@ -82,6 +82,11 @@ Press `Escape` to close the current/topmost dashboard popup. Popped-out task win
<!-- FNXC:ModalGeometryPersistenceDocs 2026-07-16-00:40: Full-screen mobile FloatingWindow sheets must preserve, rather than overwrite, the movable desktop geometry record so a later desktop reopen restores the user's chosen location and size. -->
Movable dashboard pop-outs remember their last desktop location and size, while centered resizable dialogs remember their size. When a pop-out becomes a full-screen sheet at mobile widths (or, for Artifact Gallery, its short-height sheet breakpoint), it leaves that desktop record untouched; reopening it on desktop restores the prior floating geometry.
<!-- 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.
## 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.

View File

@@ -33,6 +33,21 @@ describe("task detail modal tablet width (FN-5599, FN-6500)", () => {
expect(modalRuleMatch![0]).toContain("max-width: 98vw;");
});
it("overrides phone-sheet geometry and restores the resize grip for a known 768px tablet", () => {
const mobileBlockMatch = detailModalCss.match(/@media\s*\(max-width:\s*768px\)\s*\{[\s\S]*?\.task-modal--tablet \.modal-resize-grip\s*\{[^}]*\}[\s\S]*?\n\}/);
expect(mobileBlockMatch).toBeTruthy();
const mobileBlock = mobileBlockMatch![0];
const tabletModalRule = mobileBlock.match(/\.modal\.task-detail-modal\.task-modal--tablet\s*\{[^}]*\}/s)?.[0] ?? "";
const tabletOverlayRule = mobileBlock.match(/\.modal-overlay:has\(\.task-detail-modal\.task-modal--tablet\)\s*\{[^}]*\}/s)?.[0] ?? "";
const tabletGripRule = mobileBlock.match(/\.task-modal--tablet \.modal-resize-grip\s*\{[^}]*\}/s)?.[0] ?? "";
expect(tabletModalRule).toContain("width: 98vw;");
expect(tabletModalRule).toContain("resize: both;");
expect(tabletOverlayRule).toContain("--overlay-padding-top: 6vh;");
expect(tabletGripRule).toContain("display: block;");
});
it("keeps mobile full-screen sheet width behavior", () => {
const mobileBlockMatch = detailModalCss.match(
/@media\s*\(max-width:\s*768px\)\s*\{\s*\.detail-move-btn__arrow[\s\S]*?\.modal\.task-detail-modal\s*\{[^}]*\}[\s\S]*?\n\}/,

View File

@@ -33,7 +33,13 @@ Only the desktop FLOATING New Task dialog joins the shared cross-type floating s
FNXC:NewTask 2026-06-22-20:30:
Floating panel positioned by state-driven inline left/top/width/height. min/max keep content usable and the panel on-screen; `resize: none` because the corner/edge handles own resizing (the native grip conflicts with the pointer handlers). `pointer-events: auto` re-enables interaction on the panel only. Desktop only — mobile keeps the full-screen keyboard-aware sheet.
*/
.new-task-modal--floating {
/*
FNXC:TaskModalResize 2026-08-07-00:00:
The floating class is also applied to known tablets at the 768px CSS boundary.
Keep this selector more specific than the global phone-sheet rule; the runtime
viewport contract, not a duplicate CSS breakpoint, decides whether it applies.
*/
.modal.new-task-modal.new-task-modal--floating {
--floating-window-shadow: var(--shadow-lg);
position: fixed;
display: flex;
@@ -74,12 +80,22 @@ Header is the drag handle. `touch-action: none` (matching the resize handles) ha
FNXC:NewTask 2026-06-22-20:30:
Edge + corner resize handles. touch-action:none keeps the drag from being hijacked by scroll/gestures so resizing stays smooth.
*/
/*
FNXC:TaskModalResize 2026-07-24-19:00:
Resize grips are focusable separators on floating desktop and tablet task windows. Preserve a
visible tokenized focus ring so keyboard users can discover the same controls touch users drag.
*/
.new-task-resize-handle {
position: absolute;
z-index: 2;
touch-action: none;
}
.new-task-resize-handle:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.new-task-resize-handle--n,
.new-task-resize-handle--s {
left: var(--space-sm);

View File

@@ -1,5 +1,5 @@
import "./NewTaskModal.css";
import { useState, useCallback, useEffect, useRef, type CSSProperties, type ChangeEvent, type PointerEvent as ReactPointerEvent } from "react";
import { useState, useCallback, useEffect, useRef, type CSSProperties, type ChangeEvent, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { DEFAULT_TASK_PRIORITY, type Task, type TaskPriority } from "@fusion/core";
@@ -65,6 +65,11 @@ const NEW_TASK_DEFAULT_HEIGHT = 640;
const NEW_TASK_MIN_WIDTH = 420;
const NEW_TASK_MIN_HEIGHT = 360;
const NEW_TASK_VIEWPORT_PADDING = 16;
/*
FNXC:TaskModalResize 2026-07-24-19:00:
Keyboard resizing follows the existing viewport padding quantum, so focusable edge controls produce predictable, touch-safe geometry changes without a second sizing scale.
*/
const NEW_TASK_KEYBOARD_RESIZE_STEP = NEW_TASK_VIEWPORT_PADDING;
interface FloatSize {
width: number;
@@ -562,6 +567,34 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
captureTarget.addEventListener("pointercancel", handlePointerUp);
}, [persistPosition, persistSize, position, size]);
/*
FNXC:TaskModalResize 2026-07-24-19:00:
Tablet resize handles must be keyboard-operable as well as touch-operable. Each focused edge
adjusts the dimensions it owns, clamps and persists exactly like a completed pointer resize,
and exposes the resulting geometry through its ARIA separator value.
*/
const handleFloatingResizeKeyDown = useCallback((event: ReactKeyboardEvent<HTMLDivElement>, direction: FloatResizeDirection) => {
let widthDelta = 0;
let heightDelta = 0;
const step = NEW_TASK_KEYBOARD_RESIZE_STEP;
if (event.key === "ArrowRight") widthDelta = direction.includes("e") ? step : direction.includes("w") ? -step : 0;
if (event.key === "ArrowLeft") widthDelta = direction.includes("w") ? step : direction.includes("e") ? -step : 0;
if (event.key === "ArrowDown") heightDelta = direction.includes("s") ? step : direction.includes("n") ? -step : 0;
if (event.key === "ArrowUp") heightDelta = direction.includes("n") ? step : direction.includes("s") ? -step : 0;
if (widthDelta === 0 && heightDelta === 0) return;
event.preventDefault();
event.stopPropagation();
const nextSize = clampFloatSize({ width: size.width + widthDelta, height: size.height + heightDelta });
const nextPosition = clampFloatPosition({
x: position.x + (direction.includes("w") ? size.width - nextSize.width : 0),
y: position.y + (direction.includes("n") ? size.height - nextSize.height : 0),
}, nextSize);
persistSize(nextSize);
persistPosition(nextPosition, nextSize);
}, [persistPosition, persistSize, position, size]);
// FNXC:NewTask 2026-06-22-20:30: Run any active drag/resize teardown on unmount so element pointer listeners + a pending rAF never outlive the modal.
useEffect(() => () => dragTeardownRef.current?.(), []);
@@ -1173,7 +1206,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
style={isFloating ? { zIndex } : undefined}
>
<div
className={`modal modal-lg new-task-modal${isFloating ? " new-task-modal--floating" : ""}`}
className={`modal modal-lg new-task-modal${viewportMode === "tablet" ? " task-modal--tablet" : ""}${isFloating ? " new-task-modal--floating" : ""}`}
style={panelStyle}
onPointerDownCapture={isFloating ? bringToFront : undefined}
onFocusCapture={isFloating ? bringToFront : undefined}
@@ -1184,8 +1217,15 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
className={`new-task-resize-handle new-task-resize-handle--${direction}`}
data-testid={`new-task-resize-${direction}`}
role="separator"
aria-orientation={direction === "n" || direction === "s" ? "horizontal" : "vertical"}
aria-valuemin={direction === "n" || direction === "s" ? NEW_TASK_MIN_HEIGHT : NEW_TASK_MIN_WIDTH}
aria-valuemax={direction === "n" || direction === "s" ? Math.max(NEW_TASK_MIN_HEIGHT, window.innerHeight - NEW_TASK_VIEWPORT_PADDING * 2) : Math.max(NEW_TASK_MIN_WIDTH, window.innerWidth - NEW_TASK_VIEWPORT_PADDING * 2)}
aria-valuenow={direction === "n" || direction === "s" ? size.height : size.width}
aria-valuetext={`${t("newTaskModal.resize", "Resize new task window")}: ${size.width} by ${size.height}`}
aria-label={t("newTaskModal.resize", "Resize new task window")}
tabIndex={0}
onPointerDown={(event) => handleFloatingResizePointerDown(event, direction)}
onKeyDown={(event) => handleFloatingResizeKeyDown(event, direction)}
/>
))}
<div

View File

@@ -1786,6 +1786,36 @@ FN-6500 fixes a tablet regression from FN-5599: the task-detail overlay offset a
resize: none;
}
/*
FNXC:TaskModalResize 2026-08-07-00:00:
Phone CSS also matches a known 768px touch tablet. The shared runtime class
records the physical-screen-aware tablet result, restoring bounded modal
geometry and the existing resize grip without weakening true-phone sheets.
*/
.modal-overlay:has(.task-detail-modal.task-modal--tablet) {
--overlay-padding-top: 6vh;
padding-top: var(--overlay-padding-top);
align-items: flex-start;
justify-content: center;
}
.modal.task-detail-modal.task-modal--tablet {
width: 98vw;
min-width: 480px;
max-width: 98vw;
height: 92vh;
min-height: 480px;
max-height: calc(100dvh - var(--overlay-padding-top, 6vh) - var(--space-md));
margin: 0;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
resize: both;
}
.task-modal--tablet .modal-resize-grip {
display: block;
}
.detail-body--chat {
display: flex;
flex-direction: column;

View File

@@ -4,6 +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 { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import { useColumnLabel } from "../i18n/labels";
@@ -92,9 +93,8 @@ const ACTIVITY_VIEW_MENU_MIN_WIDTH = 160;
const ACTIVITY_VIEW_MENU_MIN_HEIGHT = 120;
const ACTIVITY_VIEW_MENU_MAX_HEIGHT = 320;
const ACTIVITY_VIEW_MENU_OPEN_VIEWPORT_GUARD_MS = 350;
// FNXC:TaskDetailSwipeBack 2026-07-05-12:30: FN-7587 — mobile breakpoint gating the presentation-only predictive-back slide/fade transition on the modal/list/nested task-detail surface; matches the `@media (max-width: 768px)` convention already used in this file.
// FNXC:TaskDetailSwipeBack 2026-07-05-12:30: FN-7587 — mobile-mode gating the presentation-only predictive-back slide/fade transition on the modal/list/nested task-detail surface uses the shared viewport classifier, so known 768px tablets do not receive phone-only presentation.
// FNXC:PlannerOversight 2026-07-05-00:00: FN-7604 — the OVERSIGHT_MENU_MOBILE_BREAKPOINT constant (formerly used to branch the oversight controls between an inline cluster and this overflow menu) was removed; the overflow-menu dropdown is now the single universal surface at every viewport, so no breakpoint gates it.
const TASK_DETAIL_MOBILE_TRANSITION_BREAKPOINT = 768;
type ActivityViewMenuPosition = {
top: number;
@@ -6523,33 +6523,25 @@ export function TaskDetailContent({
export function TaskDetailModal({ onClose, ...props }: TaskDetailModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const viewportMode = useViewportMode();
useModalResizePersist(modalRef, true, "task-detail-modal-size");
useMobileScrollLock(true);
const overlayDismissProps = useOverlayDismiss(onClose);
/*
FNXC:TaskDetailSwipeBack 2026-07-05-12:30:
FN-7587 — track the mobile breakpoint locally (mirrors the same resize-listener pattern
used elsewhere in this file) so the list/modal/nested task-detail surface gets the same
presentation-only predictive-back slide/fade enter transition as the board main-panel
(MainContent.tsx), without threading a new isMobile prop through App.tsx/AppModals.tsx. This
is presentation-only: it never touches onClose/onRequestClose timing or the underlying
useNavigationHistory dismissal routing, and honors prefers-reduced-motion (see
TaskDetailModal.css). Defaults false so JSDOM/unit tests keep exercising the desktop (no
animation) branch unless a test explicitly narrows the viewport.
FNXC:TaskDetailSwipeBack 2026-08-07-00:00:
Gate predictive-back animation through useViewportMode, the same physical-screen-aware
classifier used for resize behavior. This preserves phone animation while keeping known
768px tablets in their desktop/tablet presentation.
*/
const [isMobileTransition, setIsMobileTransition] = useState(false);
useEffect(() => {
const updateIsMobileTransition = () => {
setIsMobileTransition(window.innerWidth <= TASK_DETAIL_MOBILE_TRANSITION_BREAKPOINT);
};
const isMobileTransition = viewportMode === "mobile";
updateIsMobileTransition();
window.addEventListener("resize", updateIsMobileTransition);
return () => {
window.removeEventListener("resize", updateIsMobileTransition);
};
}, []);
/*
FNXC:TaskModalResize 2026-08-07-00:00:
Known touch tablets at the 768px CSS boundary resolve to `tablet` through
useViewportMode. Carry that single classification into the modal class so CSS
can override phone-sheet rules without a second breakpoint or gesture system.
*/
const isTabletTaskModal = viewportMode === "tablet";
return (
<div
@@ -6559,7 +6551,7 @@ export function TaskDetailModal({ onClose, ...props }: TaskDetailModalProps) {
aria-modal="true"
>
<div
className={`modal modal-lg task-detail-modal${isMobileTransition ? " task-detail-modal--mobile-transition" : ""}`}
className={`modal modal-lg task-detail-modal${isTabletTaskModal ? " task-modal--tablet" : ""}${isMobileTransition ? " task-detail-modal--mobile-transition" : ""}`}
ref={modalRef}
>
<TaskDetailContent

View File

@@ -80,7 +80,7 @@ vi.mock("../../hooks/useMobileKeyboard", () => ({
}));
// FNXC:NewTask 2026-06-22-20:30: viewport mode is switchable so we can exercise both the mobile sheet (default) and the desktop floating window. Defaults to mobile to preserve the existing suite's layout assumptions.
let mockViewportMode: "mobile" | "desktop" = "mobile";
let mockViewportMode: "mobile" | "tablet" | "desktop" = "mobile";
vi.mock("../../hooks/useViewportMode", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
isFullScreenSheetViewport: () => false,
@@ -2181,6 +2181,20 @@ describe("NewTaskModal", () => {
expect(onClose).not.toHaveBeenCalled();
});
it("keeps tablet-class New Task floating and exposes touch resize controls", () => {
mockViewportMode = "tablet";
renderNewTaskModal();
expect(screen.getByTestId("new-task-drag-handle")).toHaveClass("new-task-modal__header--draggable");
expect(document.querySelector(".new-task-modal")).toHaveClass("task-modal--tablet");
expect(document.querySelector(".new-task-modal")).toHaveClass("new-task-modal--floating");
for (const dir of ["n", "s", "e", "w", "ne", "nw", "se", "sw"]) {
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");
}
});
it("exposes a draggable header handle and resize handles", () => {
renderNewTaskModal();
@@ -2194,6 +2208,58 @@ describe("NewTaskModal", () => {
expect(panel).not.toBeNull();
});
it("resizes from a focused tablet handle with keyboard controls and exposes updated geometry", () => {
mockViewportMode = "tablet";
renderNewTaskModal();
const panel = document.querySelector(".new-task-modal--floating") as HTMLElement;
const handle = screen.getByTestId("new-task-resize-se");
const initialWidth = Number.parseFloat(panel.style.width);
const initialHeight = Number.parseFloat(panel.style.height);
handle.focus();
expect(handle).toHaveFocus();
expect(handle).toHaveAttribute("aria-valuenow", String(initialWidth));
expect(handle).toHaveAttribute("aria-valuetext", `Resize new task window: ${initialWidth} by ${initialHeight}`);
fireEvent.keyDown(handle, { key: "ArrowRight" });
fireEvent.keyDown(handle, { key: "ArrowDown" });
expect(Number.parseFloat(panel.style.width)).toBeGreaterThan(initialWidth);
expect(Number.parseFloat(panel.style.height)).toBeGreaterThan(initialHeight);
expect(handle).toHaveAttribute("aria-valuenow", panel.style.width.replace("px", ""));
expect(handle).toHaveAttribute("aria-valuetext", `Resize new task window: ${panel.style.width.replace("px", "")} by ${panel.style.height.replace("px", "")}`);
expect(JSON.parse(window.localStorage.getItem("fusion:new-task-modal-size") ?? "{}")).toMatchObject({
width: Number.parseFloat(panel.style.width),
height: Number.parseFloat(panel.style.height),
});
});
it("resizes from a tablet touch handle without dismissing or leaking selection state", () => {
mockViewportMode = "tablet";
const onClose = vi.fn();
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
callback(0);
return 1;
});
vi.stubGlobal("cancelAnimationFrame", vi.fn());
renderNewTaskModal({ onClose });
const panel = document.querySelector(".new-task-modal--floating") as HTMLElement;
const handle = screen.getByTestId("new-task-resize-se");
const initialWidth = Number.parseFloat(panel.style.width);
const initialHeight = Number.parseFloat(panel.style.height);
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: 4, clientX: 140, clientY: 130, pointerType: "touch" });
expect(Number.parseFloat(panel.style.width)).toBeGreaterThan(initialWidth);
expect(Number.parseFloat(panel.style.height)).toBeGreaterThan(initialHeight);
expect(document.body.style.userSelect).toBe("");
expect(onClose).not.toHaveBeenCalled();
});
it("keeps the floating window touch-draggable with theme-controlled shadow", () => {
const panelRule = newTaskModalCss.match(/\.new-task-modal--floating\s*\{([^}]*)\}/)?.[1] ?? "";
const headerRule = newTaskModalCss.match(/\.new-task-modal__header--draggable\s*\{([^}]*)\}/)?.[1] ?? "";

View File

@@ -843,6 +843,57 @@ describe("TaskDetailModal", () => {
}
});
it("keeps Task Detail resizable for a known touch tablet at 768px", () => {
const originalScreen = Object.getOwnPropertyDescriptor(window, "screen");
const originalMaxTouchPoints = Object.getOwnPropertyDescriptor(navigator, "maxTouchPoints");
const originalMatchMedia = window.matchMedia;
Object.defineProperty(window, "screen", { configurable: true, value: { width: 768, height: 1024 } });
Object.defineProperty(navigator, "maxTouchPoints", { configurable: true, value: 1 });
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
matches: query === "(max-width: 768px)" || query === "(max-width: 768px), (max-height: 480px)",
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})));
try {
const { container } = render(
<TaskDetailModal
task={makeTask({ column: "in-progress" as Column })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const modal = container.querySelector(".task-detail-modal");
expect(modal).toHaveClass("task-modal--tablet");
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");
// FNXC:TaskModalResize 2026-07-24-19:20: The 768px tablet recovery must
// remain keyboard discoverable, not merely restore a touch-only grip.
modal!.style.width = "500px";
modal!.style.height = "400px";
grip.focus();
fireEvent.keyDown(grip, { key: "ArrowRight" });
fireEvent.keyDown(grip, { key: "ArrowDown" });
expect(modal!.style.width).toBe("516px");
expect(modal!.style.height).toBe("416px");
expect(grip).toHaveAttribute("aria-valuenow", "516");
expect(grip).toHaveAttribute("aria-valuetext", "Width 516 pixels, height 416 pixels");
} finally {
if (originalScreen) Object.defineProperty(window, "screen", originalScreen);
if (originalMaxTouchPoints) Object.defineProperty(navigator, "maxTouchPoints", originalMaxTouchPoints);
vi.stubGlobal("matchMedia", originalMatchMedia);
}
});
it("renders responsive structural classes (modal-lg, overlay, spacer, tabs, detail-body)", () => {
const { container } = render(
<TaskDetailModal

View File

@@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import { useRef } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -159,6 +159,57 @@ describe("useModalResizePersist", () => {
.toEqual({ width: 560, height: 445 });
});
it("resizes from a focused grip with arrow keys and exposes synchronized geometry", () => {
setViewport(900);
render(<Harness />);
const modal = screen.getByTestId("modal");
installModalGeometry(modal);
triggerResizeObservers();
const grip = modal.querySelector(".modal-resize-grip") as HTMLElement;
grip.focus();
expect(grip).toHaveFocus();
expect(grip).toHaveAttribute("tabindex", "0");
expect(grip).toHaveAttribute("aria-valuenow", "500");
expect(grip).toHaveAttribute("aria-valuetext", "Width 500 pixels, height 400 pixels");
fireEvent.keyDown(grip, { key: "ArrowRight" });
fireEvent.keyDown(grip, { key: "ArrowDown" });
expect(modal.style.width).toBe("516px");
expect(modal.style.height).toBe("416px");
expect(grip).toHaveAttribute("aria-valuenow", "516");
expect(grip).toHaveAttribute("aria-valuetext", "Width 516 pixels, height 416 pixels");
vi.advanceTimersByTime(200);
expect(JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}"))
.toEqual({ width: 516, height: 416 });
});
it("keeps the grip active at the known touch-tablet 768px boundary", () => {
const originalMaxTouchPoints = Object.getOwnPropertyDescriptor(navigator, "maxTouchPoints");
setViewport(768, 1024);
Object.defineProperty(navigator, "maxTouchPoints", { configurable: true, value: 1 });
try {
render(<Harness />);
const modal = screen.getByTestId("modal");
installModalGeometry(modal);
const grip = modal.querySelector(".modal-resize-grip") as HTMLElement;
expect(grip).toBeTruthy();
dispatchPointerEvent(grip, "pointerdown", { clientX: 10, clientY: 10, pointerId: 7 });
dispatchPointerEvent(document, "pointermove", { clientX: 50, clientY: 30, pointerId: 7 });
dispatchPointerEvent(document, "pointerup", { clientX: 50, clientY: 30, pointerId: 7 });
expect(modal.style.width).toBe("540px");
expect(modal.style.height).toBe("420px");
} finally {
if (originalMaxTouchPoints) Object.defineProperty(navigator, "maxTouchPoints", originalMaxTouchPoints);
}
});
it("keeps desktop grip and native ResizeObserver persistence/restore behavior", () => {
setViewport(1280);
localStorage.setItem(STORAGE_KEY, JSON.stringify({ width: 610, height: 480 }));

View File

@@ -10,6 +10,14 @@ interface PersistedSize {
const RESIZE_GRIP_CLASS = "modal-resize-grip";
const RESIZE_GRIP_LABEL = "Resize modal from bottom-right corner";
/*
FNXC:TaskModalResize 2026-07-24-19:20:
The shared Task Detail grip must offer the same keyboard discovery as floating task windows.
Use the established modal viewport-padding quantum so arrow keys adjust its two dimensions
without creating a second resize scale or bypassing the existing persistence path.
*/
const KEYBOARD_RESIZE_STEP = 16;
function readPersistableSize(node: HTMLElement): PersistedSize {
const styleWidth = Number.parseFloat(node.style.width);
const styleHeight = Number.parseFloat(node.style.height);
@@ -95,6 +103,26 @@ export function useModalResizePersist(
}, 200);
};
const grip = document.createElement("div");
grip.className = RESIZE_GRIP_CLASS;
grip.setAttribute("role", "separator");
grip.setAttribute("aria-label", RESIZE_GRIP_LABEL);
grip.setAttribute("aria-orientation", "vertical");
grip.setAttribute("aria-valuemin", "0");
grip.setAttribute("aria-valuemax", String(window.innerWidth));
grip.tabIndex = 0;
grip.dataset.resizeDirection = "se";
existingGrip?.remove();
node.appendChild(grip);
const syncGripAria = () => {
const { width, height } = readPersistableSize(node);
if (typeof width !== "number" || typeof height !== "number") return;
grip.setAttribute("aria-valuenow", String(Math.round(width)));
grip.setAttribute("aria-valuetext", `Width ${Math.round(width)} pixels, height ${Math.round(height)} pixels`);
};
syncGripAria();
let lastSavedW = node.offsetWidth;
let lastSavedH = node.offsetHeight;
const observer =
@@ -103,6 +131,7 @@ export function useModalResizePersist(
: new ResizeObserver(() => {
const w = node.offsetWidth;
const h = node.offsetHeight;
syncGripAria();
if (w === lastSavedW && h === lastSavedH) return;
lastSavedW = w;
lastSavedH = h;
@@ -111,14 +140,6 @@ export function useModalResizePersist(
observer?.observe(node);
const grip = document.createElement("div");
grip.className = RESIZE_GRIP_CLASS;
grip.setAttribute("role", "separator");
grip.setAttribute("aria-label", RESIZE_GRIP_LABEL);
grip.dataset.resizeDirection = "se";
existingGrip?.remove();
node.appendChild(grip);
let cleanupActiveDrag: (() => void) | null = null;
const onPointerDown = (event: PointerEvent) => {
@@ -149,6 +170,7 @@ export function useModalResizePersist(
const nextHeight = startHeight + moveEvent.clientY - startY;
if (nextWidth > 0) node.style.width = `${nextWidth}px`;
if (nextHeight > 0) node.style.height = `${nextHeight}px`;
syncGripAria();
scheduleSave();
};
@@ -176,11 +198,31 @@ export function useModalResizePersist(
document.addEventListener("pointercancel", endDrag);
};
const onKeyDown = (event: KeyboardEvent) => {
let widthDelta = 0;
let heightDelta = 0;
if (event.key === "ArrowRight") widthDelta = KEYBOARD_RESIZE_STEP;
if (event.key === "ArrowLeft") widthDelta = -KEYBOARD_RESIZE_STEP;
if (event.key === "ArrowDown") heightDelta = KEYBOARD_RESIZE_STEP;
if (event.key === "ArrowUp") heightDelta = -KEYBOARD_RESIZE_STEP;
if (widthDelta === 0 && heightDelta === 0) return;
event.preventDefault();
event.stopPropagation();
const { width = 0, height = 0 } = readPersistableSize(node);
if (width + widthDelta > 0) node.style.width = `${width + widthDelta}px`;
if (height + heightDelta > 0) node.style.height = `${height + heightDelta}px`;
syncGripAria();
scheduleSave();
};
grip.addEventListener("pointerdown", onPointerDown);
grip.addEventListener("keydown", onKeyDown);
return () => {
cleanupActiveDrag?.();
grip.removeEventListener("pointerdown", onPointerDown);
grip.removeEventListener("keydown", onKeyDown);
grip.remove();
observer?.disconnect();
if (saveTimer) clearTimeout(saveTimer);