FN-8612: remove tablet task modal padding
Keep task modals dense on tablets without reducing their touch resize targets. - Move the Task Detail drag target out of layout flow while preserving its 44px hit area. - Restore desktop-density New Task header and body padding on tablet resize surfaces. - Add CSS, unit, and browser coverage for tablet geometry and generic floating windows. Files changed: .changeset/fn-8612-tablet-modal-padding.md | 7 + docs/dashboard-guide.md | 8 +- .../task-detail-modal-tablet-width.test.ts | 8 + .../dashboard/app/components/FloatingWindow.css | 40 ++++- packages/dashboard/app/components/NewTaskModal.css | 14 ++ .../FloatingWindow.touch-geometry.test.tsx | 14 ++ .../app/components/__tests__/NewTaskModal.test.tsx | 8 + .../app/task-modal-touch-resize-e2e-fixture.tsx | 54 ++++++- .../task-modal-touch-resize-browser.test.ts | 177 ++++++++++++++++++++- packages/dashboard/vitest.config.ts | 6 + 10 files changed, 322 insertions(+), 14 deletions(-) Fusion-Task-Id: FN-8612 Fusion-Task-Lineage: fecd7c48-7b6c-434e-9002-f8f21241120c Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8612-tablet-modal-padding.md
Normal file
7
.changeset/fn-8612-tablet-modal-padding.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Remove excess tablet padding from task modals while preserving touch resize targets.
|
||||
category: fix
|
||||
dev: Task-detail drag targets stay out of flow; browser coverage protects generic FloatingWindow geometry.
|
||||
@@ -86,15 +86,15 @@ 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. -->
|
||||
<!-- FNXC:TaskModalResizeDocs 2026-07-26-15:55: 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. Tablet target expansion is hit-area-only and must never add a visible panel inset. -->
|
||||
### 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. 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.
|
||||
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. The touch target is hit-area-only: task-modal headers and bodies retain desktop density without a visible tablet padding band. Their geometry stays within the viewport and is restored from browser storage on later tablet or desktop opens. True phones, narrow folded panes, and desktop coarse-pointer devices do not receive the enlarged target: phones remain full-screen sheets and desktop preserves cursor-sized resize chrome.
|
||||
|
||||
<!-- FNXC:ModalTouchGeometryDocs 2026-07-26-12:19: FloatingWindow is the shared move/resize primitive; its tablet target contract must remain discriminator-composed so 768px tablets never collide with phone sheets. -->
|
||||
<!-- FNXC:ModalTouchGeometryDocs 2026-07-26-15:55: FloatingWindow is shared by task and utility surfaces. Task-detail density overrides are class-scoped and a classless browser fixture control proves the generic 44px geometry remains unchanged. -->
|
||||
### Shared floating-window touch contract
|
||||
|
||||
Use `FloatingWindow` for a moveable and resizable dashboard surface rather than adding per-modal pointer code. On known tablet touch viewports it uses `isTabletTouchViewport`, applies `data-resize-hit-target="true"` to the drag handle and all eight edge/corner handles, and expands only their hit areas to the shared 44px target without thickening painted borders or covering content/footer controls. Never gate these controls on bare `(pointer: coarse)`: desktop hybrids keep desktop geometry. Phone full-screen sheets are strictly **below 768px** (`max-width: 767.98px`); a 768px viewport is tablet-class, so JS geometry and CSS must preserve active targets there.
|
||||
Use `FloatingWindow` for a moveable and resizable dashboard surface rather than adding per-modal pointer code. On known tablet touch viewports it uses `isTabletTouchViewport`, applies `data-resize-hit-target="true"` to the drag handle and all eight edge/corner handles, and expands only their hit areas to the shared 44px target without thickening painted borders or covering content/footer controls. Task-detail uses an out-of-flow drag target so its hit area does not add painted header padding; this density adjustment is scoped to task modals, while a classless browser-fixture control verifies that generic FloatingWindow geometry stays unchanged. Never gate these controls on bare `(pointer: coarse)`: desktop hybrids keep desktop geometry. Phone full-screen sheets are strictly **below 768px** (`max-width: 767.98px`); a 768px viewport is tablet-class, so JS geometry and CSS must preserve active targets there.
|
||||
|
||||
<!-- FNXC:ModalTouchGeometryDocs 2026-07-26-13:50: Core/workflow modal migrations use stable window keys so one shared primitive owns drag, resize, clamping, stacking, and persisted geometry. -->
|
||||
|
||||
|
||||
@@ -33,6 +33,14 @@ describe("task detail modal tablet width (FN-5599, FN-6500)", () => {
|
||||
expect(modalRuleMatch![0]).toContain("max-width: 98vw;");
|
||||
});
|
||||
|
||||
it("keeps the tablet touch resize grip out of task-detail layout padding", () => {
|
||||
const touchSurfaceRule = detailModalCss.match(/\.modal\.task-detail-modal\.task-modal--touch-resize\s*\{[^}]*\}/s)?.[0] ?? "";
|
||||
|
||||
expect(touchSurfaceRule).toContain("overflow: visible;");
|
||||
expect(touchSurfaceRule).not.toMatch(/\bpadding(?:-[\w-]+)?:/);
|
||||
expect(touchSurfaceRule).not.toMatch(/\bmargin(?:-[\w-]+)?:/);
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
@@ -132,9 +132,8 @@ not cover close controls, hosted footer actions, or scrollable content.
|
||||
/*
|
||||
FNXC:ModalTouchGeometry 2026-07-26-12:34:
|
||||
A headerless window delegates dragging to a caller-owned header, so that resolved element must
|
||||
receive the same tablet-only target floor as FloatingWindow's built-in header. `min-block-size`
|
||||
enlarges only the interactive header box when necessary; it does not thicken resize borders or
|
||||
extend an invisible target into hosted content, close controls, or footer actions.
|
||||
receive the same tablet-only target floor as FloatingWindow's built-in header. Generic windows
|
||||
retain that established 44px layout contract.
|
||||
*/
|
||||
.floating-window--touch-geometry .floating-window__delegated-drag-handle {
|
||||
box-sizing: border-box;
|
||||
@@ -144,6 +143,41 @@ extend an invisible target into hosted content, close controls, or footer action
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:ModalTouchGeometry 2026-07-26-15:30:
|
||||
Task-detail pop-outs are the only delegated-header surface with the reported tablet density
|
||||
regression. Their 44px drag contract is hit-area-only: an absolute pseudo-element extends the
|
||||
pointer target without making the painted task header taller. Keep the target below controls so a
|
||||
close or action button receives its tap. Do not widen this selector because FloatingWindow also
|
||||
hosts unrelated chat, import, workflow, and utility panels.
|
||||
*/
|
||||
.floating-window--task-detail.floating-window--touch-geometry .floating-window__delegated-drag-handle {
|
||||
min-block-size: 0;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.floating-window--task-detail.floating-window--touch-geometry .floating-window__delegated-drag-handle::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
inset-inline: 0;
|
||||
block-size: var(--modal-resize-touch-target);
|
||||
inset-block-start: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:ModalTouchGeometry 2026-07-26-16:00:
|
||||
The invisible tablet drag extension must stay beneath actionable task-header descendants.
|
||||
Explicit stacking preserves the 44px parent hit area while buttons and links retain their normal
|
||||
activation instead of beginning a window drag.
|
||||
*/
|
||||
.floating-window--task-detail.floating-window--touch-geometry .floating-window__delegated-drag-handle :is(button, a, input, select, textarea, [role="button"]) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.floating-window--touch-geometry .floating-window__delegated-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
@@ -794,6 +794,20 @@ The New Task create workflow selector is a real dropdown so workflow identity ic
|
||||
padding: var(--space-md) var(--space-sm);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:TaskModalResize 2026-07-26-15:30:
|
||||
The physical-screen tablet path shares this CSS query with phone sheets. Restore the desktop
|
||||
body density only when the runtime tablet class is present; phones keep their compact sheet
|
||||
padding while the tablet resize surface gains no visible inset from its touch controls.
|
||||
*/
|
||||
.new-task-modal.task-modal--tablet .modal-header {
|
||||
padding: var(--modal-padding);
|
||||
}
|
||||
|
||||
.new-task-modal.task-modal--tablet .modal-body {
|
||||
padding: var(--space-xl);
|
||||
}
|
||||
|
||||
.new-task-modal .form-group {
|
||||
padding: 0 var(--space-md);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const viewport = vi.hoisted(() => ({ tabletTouch: false }));
|
||||
@@ -15,6 +17,7 @@ vi.mock("../../hooks/useViewportMode", async () => {
|
||||
import { FloatingWindow } from "../FloatingWindow";
|
||||
|
||||
const directions = ["n", "s", "e", "w", "ne", "nw", "se", "sw"] as const;
|
||||
const floatingWindowCss = readFileSync(resolve(__dirname, "../FloatingWindow.css"), "utf8");
|
||||
|
||||
function renderWindow(key = "touch-geometry") {
|
||||
return render(
|
||||
@@ -62,6 +65,17 @@ describe("FloatingWindow tablet touch geometry", () => {
|
||||
expect(screen.getByTestId("floating-window-drag-handle-touch-geometry")).toHaveAttribute("data-resize-hit-target", "true");
|
||||
});
|
||||
|
||||
it("keeps generic delegated headers at the shared 44px layout target while task detail uses an out-of-flow target", () => {
|
||||
const genericRule = floatingWindowCss.match(/\.floating-window--touch-geometry \.floating-window__delegated-drag-handle\s*\{[^}]*\}/s)?.[0] ?? "";
|
||||
const taskRule = floatingWindowCss.match(/\.floating-window--task-detail\.floating-window--touch-geometry \.floating-window__delegated-drag-handle\s*\{[^}]*\}/s)?.[0] ?? "";
|
||||
const taskTargetRule = floatingWindowCss.match(/\.floating-window--task-detail\.floating-window--touch-geometry \.floating-window__delegated-drag-handle::before\s*\{[^}]*\}/s)?.[0] ?? "";
|
||||
|
||||
expect(genericRule).toContain("min-block-size: var(--modal-resize-touch-target);");
|
||||
expect(taskRule).toContain("min-block-size: 0;");
|
||||
expect(taskTargetRule).toContain("block-size: var(--modal-resize-touch-target);");
|
||||
expect(taskTargetRule).toContain("position: absolute;");
|
||||
});
|
||||
|
||||
it("applies the touch contract and drag gesture to a headerless delegated handle", () => {
|
||||
viewport.tabletTouch = true;
|
||||
render(
|
||||
|
||||
@@ -155,6 +155,14 @@ describe("NewTaskModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("restores desktop body density only for the 768px tablet resize class", () => {
|
||||
const tabletRule = newTaskModalCss.match(/\.new-task-modal\.task-modal--tablet \.modal-body\s*\{[^}]*\}/s)?.[0] ?? "";
|
||||
const phoneRule = newTaskModalCss.match(/\.new-task-modal \.modal-body\s*\{[^}]*padding:\s*var\(--space-md\) var\(--space-sm\);[^}]*\}/s)?.[0] ?? "";
|
||||
|
||||
expect(tabletRule).toContain("padding: var(--space-xl);");
|
||||
expect(phoneRule).toContain("padding: var(--space-md) var(--space-sm);");
|
||||
});
|
||||
|
||||
it("applies keyboard CSS variables when mobile keyboard is open", () => {
|
||||
mockUseMobileKeyboard.mockReturnValue({
|
||||
keyboardOpen: true,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useRef } from "react";
|
||||
import React, { useRef, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import i18n from "i18next";
|
||||
import { I18nextProvider, initReactI18next } from "react-i18next";
|
||||
@@ -15,6 +15,20 @@ const params = new URLSearchParams(window.location.search);
|
||||
const surface = params.get("surface") ?? "new-task";
|
||||
if (params.has("reset")) localStorage.clear();
|
||||
|
||||
/*
|
||||
FNXC:TaskModalResize 2026-07-26-16:00:
|
||||
The browser fixture seeds the production persistence path only for resize gestures that need
|
||||
headroom. It never supplies inline panel geometry, so density assertions continue to exercise
|
||||
TaskDetailModal.css's real tablet overlay and width rules.
|
||||
*/
|
||||
const detailSize = params.get("detailSize");
|
||||
if (detailSize) {
|
||||
const [width, height] = detailSize.split("x").map(Number);
|
||||
if (Number.isFinite(width) && Number.isFinite(height)) {
|
||||
localStorage.setItem("task-detail-modal-size", JSON.stringify({ width, height }));
|
||||
}
|
||||
}
|
||||
|
||||
void i18n.use(initReactI18next).init({
|
||||
lng: "en",
|
||||
fallbackLng: "en",
|
||||
@@ -38,9 +52,9 @@ function TaskDetailResizeHarness() {
|
||||
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>
|
||||
return <div className="modal-overlay open">
|
||||
<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">
|
||||
<div className="task-detail-content"><div className="modal-header">Task detail</div><div className="modal-body">Task detail body</div></div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -62,6 +76,7 @@ function FloatingWindowHarness() {
|
||||
}
|
||||
|
||||
function HeaderlessFloatingWindowHarness() {
|
||||
const [actionCount, setActionCount] = useState(0);
|
||||
return <FloatingWindow
|
||||
windowKey="fn-8605-headerless-floating"
|
||||
title="Headerless floating task detail"
|
||||
@@ -75,15 +90,42 @@ function HeaderlessFloatingWindowHarness() {
|
||||
persistGeometryKey="fusion:fn-8605-headerless-floating"
|
||||
suspendGeometryPersistenceOnMobile
|
||||
>
|
||||
<div className="fn-8605-delegated-drag-handle">Headerless task detail</div>
|
||||
<div className="fn-8605-delegated-drag-handle">Headerless task detail
|
||||
<button type="button" data-testid="fn-8605-header-action" onClick={() => setActionCount((count) => count + 1)}>Header action</button>
|
||||
<output data-testid="fn-8605-header-action-count">{actionCount}</output>
|
||||
</div>
|
||||
<div>Floating task detail body</div>
|
||||
</FloatingWindow>;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:ModalTouchGeometry 2026-07-26-15:30:
|
||||
This intentionally classless headerless window is the browser control for every non-task
|
||||
FloatingWindow consumer. It must retain the shared 44px layout target while task detail moves
|
||||
its target out of flow.
|
||||
*/
|
||||
function GenericFloatingWindowHarness() {
|
||||
return <FloatingWindow
|
||||
windowKey="fn-8612-generic-floating"
|
||||
title="Generic floating window"
|
||||
onClose={() => undefined}
|
||||
hideHeader
|
||||
dragHandleSelector=".fn-8612-generic-drag-handle"
|
||||
defaultSize={{ width: 560, height: 480 }}
|
||||
defaultPosition={{ x: 80, y: 80 }}
|
||||
minSize={{ width: 320, height: 240 }}
|
||||
persistGeometryKey="fusion:fn-8612-generic-floating"
|
||||
suspendGeometryPersistenceOnMobile
|
||||
>
|
||||
<div className="fn-8612-generic-drag-handle">Generic window header</div>
|
||||
<div>Generic floating window body</div>
|
||||
</FloatingWindow>;
|
||||
}
|
||||
|
||||
function Fixture() {
|
||||
return <I18nextProvider i18n={i18n}>
|
||||
<ConfirmDialogProvider skipConfirmations>
|
||||
{surface === "floating-window" ? <FloatingWindowHarness /> : surface === "floating-window-headerless" ? <HeaderlessFloatingWindowHarness /> : surface === "task-detail" ? <TaskDetailResizeHarness /> : <NewTaskModal
|
||||
{surface === "floating-window" ? <FloatingWindowHarness /> : surface === "floating-window-headerless" ? <HeaderlessFloatingWindowHarness /> : surface === "floating-window-generic" ? <GenericFloatingWindowHarness /> : surface === "task-detail" ? <TaskDetailResizeHarness /> : <NewTaskModal
|
||||
isOpen
|
||||
tasks={[]}
|
||||
onClose={() => undefined}
|
||||
|
||||
@@ -29,6 +29,11 @@ async function touchDrag(cdp: Cdp, point: Point, delta = { x: 48, y: 36 }) {
|
||||
await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
|
||||
}
|
||||
|
||||
async function touchTap(cdp: Cdp, point: Point) {
|
||||
await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x: point.x, y: point.y, 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,
|
||||
@@ -41,6 +46,54 @@ async function setTabletMetrics(cdp: Cdp, width: number, height: number) {
|
||||
await cdp.send("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 1 });
|
||||
}
|
||||
|
||||
async function setDesktopMetrics(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: false });
|
||||
}
|
||||
|
||||
interface BoxMetrics extends Rect {
|
||||
paddingBlockStart: string;
|
||||
paddingBlockEnd: string;
|
||||
paddingInlineStart: string;
|
||||
paddingInlineEnd: string;
|
||||
borderBlockEndWidth: string;
|
||||
borderInlineEndWidth: string;
|
||||
minBlockSize: string;
|
||||
}
|
||||
|
||||
async function boxMetrics(page: Page, selector: string): Promise<BoxMetrics> {
|
||||
return page.evaluate((target) => {
|
||||
const element = document.querySelector<HTMLElement>(target);
|
||||
if (!element) throw new Error(`Missing ${target}`);
|
||||
const computed = getComputedStyle(element);
|
||||
const { x, y, width, height } = element.getBoundingClientRect();
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
paddingBlockStart: computed.paddingBlockStart,
|
||||
paddingBlockEnd: computed.paddingBlockEnd,
|
||||
paddingInlineStart: computed.paddingInlineStart,
|
||||
paddingInlineEnd: computed.paddingInlineEnd,
|
||||
borderBlockEndWidth: computed.borderBlockEndWidth,
|
||||
borderInlineEndWidth: computed.borderInlineEndWidth,
|
||||
minBlockSize: computed.minBlockSize,
|
||||
};
|
||||
}, selector);
|
||||
}
|
||||
|
||||
async function cornerPaintInset(page: Page): Promise<number> {
|
||||
return page.evaluate(() => Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--space-lg")));
|
||||
}
|
||||
|
||||
async function rect(page: Page, selector: string): Promise<Rect> {
|
||||
return page.evaluate((target) => {
|
||||
const panel = document.querySelector<HTMLElement>(target);
|
||||
@@ -85,7 +138,7 @@ describe.runIf(executablePath)("Task modal tablet touch resize browser regressio
|
||||
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.goto(`${baseUrl}app/task-modal-touch-resize-e2e-fixture.html?surface=task-detail&reset=1&detailSize=600x500`);
|
||||
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);
|
||||
@@ -191,6 +244,13 @@ describe.runIf(executablePath)("Task modal tablet touch resize browser regressio
|
||||
const headerPoint = await targetCenter(page, headerSelector);
|
||||
expect(await page.evaluate((point) => document.elementFromPoint(point.x, point.y)?.getAttribute("data-resize-hit-target"), headerPoint)).toBe("true");
|
||||
const panelSelector = "[data-testid='floating-window-fn-8605-headerless-floating']";
|
||||
const actionPoint = await targetCenter(page, "[data-testid='fn-8605-header-action']");
|
||||
const beforeAction = await rect(page, panelSelector);
|
||||
await touchTap(cdp, actionPoint);
|
||||
await page.waitForTimeout(100);
|
||||
expect(await page.evaluate(() => document.querySelector("[data-testid='fn-8605-header-action-count']")?.textContent)).toBe("1");
|
||||
expect(await rect(page, panelSelector)).toEqual(beforeAction);
|
||||
|
||||
const beforeDrag = await rect(page, panelSelector);
|
||||
await touchDrag(cdp, headerPoint, { x: 28, y: 24 });
|
||||
await page.waitForTimeout(100);
|
||||
@@ -203,6 +263,121 @@ describe.runIf(executablePath)("Task modal tablet touch resize browser regressio
|
||||
}, 30_000);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:ModalTouchGeometry 2026-07-26-15:30:
|
||||
Browser layout metrics, rather than jsdom stylesheet matching, prove that tablet touch targets
|
||||
do not create painted task-modal padding. Panel-relative corner-handle offsets keep the
|
||||
classless control honest: this narrow task correction cannot change shared utility panels.
|
||||
*/
|
||||
it("keeps task-modal density at desktop metrics while preserving tablet targets", async () => {
|
||||
const desktop = await browser.newPage({ viewport: { width: 1200, height: 1000 } });
|
||||
const desktopCdp = await desktop.context().newCDPSession(desktop);
|
||||
await setDesktopMetrics(desktopCdp, 1200, 1000);
|
||||
|
||||
await desktop.goto(`${baseUrl}app/task-modal-touch-resize-e2e-fixture.html?surface=task-detail&reset=1`);
|
||||
await desktop.waitForTimeout(250);
|
||||
const desktopTaskDetail = {
|
||||
panel: await boxMetrics(desktop, "[data-testid='task-detail-modal']"),
|
||||
header: await boxMetrics(desktop, ".task-detail-modal .modal-header"),
|
||||
body: await boxMetrics(desktop, ".task-detail-modal .modal-body"),
|
||||
overlay: await boxMetrics(desktop, ".modal-overlay"),
|
||||
};
|
||||
// The desktop overlay is the intentional density baseline: 10vh top placement with no
|
||||
// panel-internal padding added by touch geometry.
|
||||
expect(parseFloat(desktopTaskDetail.overlay.paddingBlockStart)).toBeCloseTo(100);
|
||||
expect(desktopTaskDetail.panel.y).toBeCloseTo(parseFloat(desktopTaskDetail.overlay.paddingBlockStart));
|
||||
await desktop.goto(`${baseUrl}app/task-modal-touch-resize-e2e-fixture.html?surface=new-task&reset=1`);
|
||||
await desktop.waitForTimeout(250);
|
||||
const desktopNewTask = {
|
||||
header: await boxMetrics(desktop, ".new-task-modal .modal-header"),
|
||||
body: await boxMetrics(desktop, ".new-task-modal .modal-body"),
|
||||
overlay: await boxMetrics(desktop, "[data-testid='new-task-modal-overlay']"),
|
||||
panel: await boxMetrics(desktop, ".new-task-modal"),
|
||||
};
|
||||
|
||||
const tablet = await browser.newPage({ viewport: { width: 768, height: 1024 } });
|
||||
const tabletCdp = await tablet.context().newCDPSession(tablet);
|
||||
await setTabletMetrics(tabletCdp, 768, 1024);
|
||||
await tablet.goto(`${baseUrl}app/task-modal-touch-resize-e2e-fixture.html?surface=task-detail&reset=1`);
|
||||
await tablet.waitForTimeout(250);
|
||||
const tabletTaskDetail = {
|
||||
panel: await boxMetrics(tablet, "[data-testid='task-detail-modal']"),
|
||||
header: await boxMetrics(tablet, ".task-detail-modal .modal-header"),
|
||||
body: await boxMetrics(tablet, ".task-detail-modal .modal-body"),
|
||||
overlay: await boxMetrics(tablet, ".modal-overlay"),
|
||||
grip: await boxMetrics(tablet, ".task-detail-modal .modal-resize-grip"),
|
||||
};
|
||||
// The production tablet breakpoint intentionally sizes the panel to 98vw and offsets it
|
||||
// by 6vh. Checking computed overlay padding plus the actual panel rect catches a painted
|
||||
// inset regression that the fixed overlay's own y=0 rect cannot observe.
|
||||
expect(parseFloat(tabletTaskDetail.overlay.paddingBlockStart)).toBeCloseTo(1024 * 0.06);
|
||||
expect(tabletTaskDetail.panel.y).toBeCloseTo(parseFloat(tabletTaskDetail.overlay.paddingBlockStart));
|
||||
expect(tabletTaskDetail.panel.width).toBeCloseTo(768 * 0.98, 1);
|
||||
expect(tabletTaskDetail.panel.x).toBeCloseTo((768 - tabletTaskDetail.panel.width) / 2, 1);
|
||||
expect(tabletTaskDetail.overlay.paddingInlineStart).toBe(desktopTaskDetail.overlay.paddingInlineStart);
|
||||
expect(tabletTaskDetail.overlay.paddingInlineEnd).toBe(desktopTaskDetail.overlay.paddingInlineEnd);
|
||||
expect(tabletTaskDetail.panel.width).toBeLessThan(desktopTaskDetail.panel.width);
|
||||
expect(tabletTaskDetail.header.paddingBlockStart).toBe(desktopTaskDetail.header.paddingBlockStart);
|
||||
expect(tabletTaskDetail.header.paddingBlockEnd).toBe(desktopTaskDetail.header.paddingBlockEnd);
|
||||
expect(tabletTaskDetail.body.paddingBlockStart).toBe(desktopTaskDetail.body.paddingBlockStart);
|
||||
expect(tabletTaskDetail.body.paddingBlockEnd).toBe(desktopTaskDetail.body.paddingBlockEnd);
|
||||
expect(tabletTaskDetail.grip.width).toBe(44);
|
||||
expect(tabletTaskDetail.grip.height).toBe(44);
|
||||
|
||||
await tablet.goto(`${baseUrl}app/task-modal-touch-resize-e2e-fixture.html?surface=new-task&reset=1`);
|
||||
await tablet.waitForTimeout(250);
|
||||
const tabletNewTask = {
|
||||
header: await boxMetrics(tablet, ".new-task-modal .modal-header"),
|
||||
body: await boxMetrics(tablet, ".new-task-modal .modal-body"),
|
||||
overlay: await boxMetrics(tablet, "[data-testid='new-task-modal-overlay']"),
|
||||
panel: await boxMetrics(tablet, ".new-task-modal"),
|
||||
handle: await boxMetrics(tablet, "[data-testid='new-task-resize-se']"),
|
||||
};
|
||||
expect(tabletNewTask.header.paddingBlockStart).toBe(desktopNewTask.header.paddingBlockStart);
|
||||
expect(tabletNewTask.header.paddingBlockEnd).toBe(desktopNewTask.header.paddingBlockEnd);
|
||||
expect(tabletNewTask.body.paddingBlockStart).toBe(desktopNewTask.body.paddingBlockStart);
|
||||
expect(tabletNewTask.body.paddingBlockEnd).toBe(desktopNewTask.body.paddingBlockEnd);
|
||||
expect(tabletNewTask.overlay.paddingBlockStart).toBe(desktopNewTask.overlay.paddingBlockStart);
|
||||
expect(tabletNewTask.overlay.paddingBlockEnd).toBe(desktopNewTask.overlay.paddingBlockEnd);
|
||||
expect(tabletNewTask.panel.width).toBe(desktopNewTask.panel.width);
|
||||
// Floating New Task repositions into the tablet viewport; its panel width and zero overlay
|
||||
// inset remain the desktop-density contract rather than inheriting task-detail placement.
|
||||
expect(tabletNewTask.panel.x).toBeGreaterThanOrEqual(0);
|
||||
expect(tabletNewTask.panel.x).toBeLessThan(desktopNewTask.panel.x);
|
||||
expect(tabletNewTask.handle.width).toBe(44);
|
||||
expect(tabletNewTask.handle.height).toBe(44);
|
||||
|
||||
await tablet.goto(`${baseUrl}app/task-modal-touch-resize-e2e-fixture.html?surface=floating-window-headerless&reset=1`);
|
||||
await tablet.waitForTimeout(250);
|
||||
const taskHeader = await boxMetrics(tablet, ".fn-8605-delegated-drag-handle");
|
||||
expect(taskHeader.minBlockSize).toBe("0px");
|
||||
|
||||
await tablet.goto(`${baseUrl}app/task-modal-touch-resize-e2e-fixture.html?surface=floating-window-generic&reset=1`);
|
||||
await tablet.waitForTimeout(250);
|
||||
const genericPanel = await boxMetrics(tablet, "[data-testid='floating-window-fn-8612-generic-floating']");
|
||||
const genericHeader = await boxMetrics(tablet, ".fn-8612-generic-drag-handle");
|
||||
const genericHandle = await boxMetrics(tablet, "[data-testid='floating-window-resize-se']");
|
||||
expect(genericPanel.width).toBe(560);
|
||||
expect(genericPanel.height).toBe(480);
|
||||
expect(genericHeader.minBlockSize).toBe("44px");
|
||||
expect(genericHandle.width).toBe(44);
|
||||
expect(genericHandle.height).toBe(44);
|
||||
// The shared southeast handle deliberately overhangs the panel by the touch target minus
|
||||
// its painted --space-lg corner. Include the panel border in both axis baselines so this
|
||||
// task-only density correction cannot silently alter generic handle placement.
|
||||
const genericCornerInset = await cornerPaintInset(tablet);
|
||||
expect(genericHandle.x - genericPanel.x).toBeCloseTo(genericPanel.width - genericCornerInset - parseFloat(genericPanel.borderInlineEndWidth));
|
||||
expect(genericHandle.y - genericPanel.y).toBeCloseTo(genericPanel.height - genericCornerInset - parseFloat(genericPanel.borderBlockEndWidth));
|
||||
const genericResizePoint = await targetCenter(tablet, "[data-testid='floating-window-resize-se']");
|
||||
const genericHeaderPoint = await targetCenter(tablet, ".fn-8612-generic-drag-handle");
|
||||
expect(await tablet.evaluate((point) => document.elementFromPoint(point.x, point.y)?.getAttribute("data-resize-hit-target"), genericResizePoint)).toBe("true");
|
||||
expect(await tablet.evaluate((point) => document.elementFromPoint(point.x, point.y)?.getAttribute("data-resize-hit-target"), genericHeaderPoint)).toBe("true");
|
||||
expect(await tablet.evaluate(() => document.querySelector(".floating-window--task-detail") === null)).toBe(true);
|
||||
|
||||
await desktop.close();
|
||||
await tablet.close();
|
||||
}, 30_000);
|
||||
|
||||
it("keeps the 767px FloatingWindow phone sheet free of active targets", async () => {
|
||||
const page = await browser.newPage({ viewport: { width: 767, height: 1024 } });
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
|
||||
@@ -506,6 +506,12 @@ export default defineConfig({
|
||||
Must precede the `@fusion/core` alias: Vite string aliases match by PREFIX, so the broader key would rewrite this subpath to `index.ts/detect-content-language` and fail to resolve.
|
||||
*/
|
||||
"@fusion/core/detect-content-language": resolve(__dirname, "../core/src/detect-content-language.ts"),
|
||||
/*
|
||||
FNXC:VitestAliases 2026-07-26-15:45:
|
||||
Dashboard client tests import the browser-safe delete-attribution leaf through api/client.
|
||||
Keep this exact alias before the broader core alias so Vite does not rewrite the subpath.
|
||||
*/
|
||||
"@fusion/core/task-delete-attribution": resolve(__dirname, "../core/src/task-delete-attribution.ts"),
|
||||
"@fusion/core": resolve(__dirname, "../core/src/index.ts"),
|
||||
"@fusion/engine": resolve(__dirname, "../engine/src/index.ts"),
|
||||
"@fusion/plugin-sdk": resolve(__dirname, "../plugin-sdk/src/index.ts"),
|
||||
|
||||
Reference in New Issue
Block a user