From 4b4df1ff30950b7ac5ce6ca26a531c996df1d1a9 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 24 Jul 2026 19:20:16 -0700 Subject: [PATCH] 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) --- .../fn-8565-tablet-task-modal-resize.md | 7 ++ docs/dashboard-guide.md | 5 ++ .../task-detail-modal-tablet-width.test.ts | 15 ++++ .../dashboard/app/components/NewTaskModal.css | 18 ++++- .../dashboard/app/components/NewTaskModal.tsx | 44 +++++++++++- .../app/components/TaskDetailModal.css | 30 ++++++++ .../app/components/TaskDetailModal.tsx | 40 +++++------ .../__tests__/NewTaskModal.test.tsx | 68 ++++++++++++++++++- ...Modal.responsive-and-dependencies.test.tsx | 51 ++++++++++++++ .../__tests__/useModalResizePersist.test.tsx | 53 ++++++++++++++- .../app/hooks/useModalResizePersist.ts | 58 +++++++++++++--- 11 files changed, 352 insertions(+), 37 deletions(-) create mode 100644 .changeset/fn-8565-tablet-task-modal-resize.md diff --git a/.changeset/fn-8565-tablet-task-modal-resize.md b/.changeset/fn-8565-tablet-task-modal-resize.md new file mode 100644 index 0000000000..18c373c169 --- /dev/null +++ b/.changeset/fn-8565-tablet-task-modal-resize.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 2333f74211..7e22b1e4ca 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -82,6 +82,11 @@ Press `Escape` to close the current/topmost dashboard popup. Popped-out task win 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. + +### 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. diff --git a/packages/dashboard/app/__tests__/task-detail-modal-tablet-width.test.ts b/packages/dashboard/app/__tests__/task-detail-modal-tablet-width.test.ts index 25a3027dc7..2bd35f97a6 100644 --- a/packages/dashboard/app/__tests__/task-detail-modal-tablet-width.test.ts +++ b/packages/dashboard/app/__tests__/task-detail-modal-tablet-width.test.ts @@ -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\}/, diff --git a/packages/dashboard/app/components/NewTaskModal.css b/packages/dashboard/app/components/NewTaskModal.css index 1952f23037..cc93529d60 100644 --- a/packages/dashboard/app/components/NewTaskModal.css +++ b/packages/dashboard/app/components/NewTaskModal.css @@ -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); diff --git a/packages/dashboard/app/components/NewTaskModal.tsx b/packages/dashboard/app/components/NewTaskModal.tsx index 9b87f3a875..ebee450274 100644 --- a/packages/dashboard/app/components/NewTaskModal.tsx +++ b/packages/dashboard/app/components/NewTaskModal.tsx @@ -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, 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} >
handleFloatingResizePointerDown(event, direction)} + onKeyDown={(event) => handleFloatingResizeKeyDown(event, direction)} /> ))}
(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 (
({ })); // 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] ?? ""; diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx index e43b480ab0..e08f3170c6 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx @@ -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( + , + ); + 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( { .toEqual({ width: 560, height: 445 }); }); + it("resizes from a focused grip with arrow keys and exposes synchronized geometry", () => { + setViewport(900); + render(); + + 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(); + 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 })); diff --git a/packages/dashboard/app/hooks/useModalResizePersist.ts b/packages/dashboard/app/hooks/useModalResizePersist.ts index 5417bcd872..edaec4196c 100644 --- a/packages/dashboard/app/hooks/useModalResizePersist.ts +++ b/packages/dashboard/app/hooks/useModalResizePersist.ts @@ -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);