Files
fusion/packages/dashboard/app/components/FloatingWindow.css
gsxdsm 47087342fc FN-8605: harden tablet touch window controls
Make shared floating dashboard windows reliably movable and resizable on tablet touch viewports.

- Apply a tablet-aware 44px drag and resize hit-target contract to FloatingWindow.
- Preserve phone full-screen sheets below 768px while retaining desktop-hybrid geometry.
- Add geometry, browser touch, and viewport boundary coverage with captured visual evidence.
- Document the shared modal touch contract and release the dashboard fix.

Files changed:
 .changeset/fn-8605-floating-window-touch.md        |   7 ++
 docs/dashboard-guide.md                            |   5 +
 docs/testing.md                                    |   2 +-
 .../dashboard/app/components/FloatingWindow.css    | 116 ++++++++++++++----
 .../dashboard/app/components/FloatingWindow.tsx    |  56 ++++++++-
 .../components/__tests__/FloatingWindow.test.tsx   |  80 +++---------
 .../FloatingWindow.touch-geometry.test.tsx         | 136 +++++++++++++++++++++
 .../FloatingWindowStack.cross-type.test.tsx        |   2 +-
 .../app/hooks/__tests__/useViewportMode.test.ts    |  18 ++-
 packages/dashboard/app/hooks/useViewportMode.ts    |   7 +-
 .../app/task-modal-touch-resize-e2e-fixture.tsx    |  39 +++++-
 .../__screenshots__/fn-8605/phone-fullscreen.png   | Bin 0 -> 11393 bytes
 .../e2e/__screenshots__/fn-8605/tablet-after.png   | Bin 0 -> 14275 bytes
 .../e2e/__screenshots__/fn-8605/tablet-before.png  | Bin 0 -> 14308 bytes
 .../task-modal-touch-resize-browser.test.ts        |  83 +++++++++++++
 15 files changed, 453 insertions(+), 98 deletions(-)

Fusion-Task-Id: FN-8605

Fusion-Task-Lineage: 993c7ed8-c3e9-4673-b0b7-5c50746991a7

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-26 12:51:39 -07:00

434 lines
16 KiB
CSS

/*
FNXC:FloatingWindow 2026-06-22-20:45:
FloatingWindow is a non-blocking floating window (generalized from RightDockExpandModal). The overlay is a full-viewport, transparent, NON-dimming, NON-blurring, click-through layer: `pointer-events: none` lets every click pass through to the app and to other windows behind it. Only the panel re-enables `pointer-events: auto`. Multiple overlays/panels coexist with no mutual blocking — z-stacking is driven by inline `z-index` from the component's per-window counter.
FNXC:FloatingWindow 2026-06-27-00:00:
Click-through overlays cannot implement backdrop clicks in CSS/DOM structure. Outside-click dismissal is therefore a component-level opt-in document listener for transient windows such as Quick Chat; persistent task and terminal pop-outs keep the default non-dismissable page-click behavior.
*/
.floating-window-overlay {
position: fixed;
inset: 0;
background: transparent;
backdrop-filter: none;
pointer-events: none;
}
/*
FNXC:FloatingWindow 2026-07-18-14:05:
FN-8340 resolves #2114: Quick Chat minimize must retain its desktop window and ≤768px full-screen
sheet in the layout tree. `visibility: hidden` removes paint while preserving child geometry and
message-list scroll state; `pointer-events: none` keeps the already click-through overlay inert.
Never replace this with `display: none`, which collapses layout and makes reopening visibly reflow.
*/
.floating-window-overlay--hidden {
visibility: hidden;
pointer-events: none;
}
/*
FNXC:FloatingWindow 2026-06-22-20:45:
Floating panel positioned by state-driven inline `left/top/width/height` and stacked by inline `z-index`. min/max keep the panel usable and on-screen. `resize: none` because resizing is handled by the corner/edge handles. `pointer-events: auto` re-enables interaction on the panel only.
*/
.floating-window {
--floating-window-shadow: var(--shadow-lg);
position: fixed;
display: flex;
flex-direction: column;
min-width: calc(var(--space-2xl) * 7.5);
min-height: calc(var(--space-2xl) * 5.83);
max-width: calc(100vw - (var(--space-lg) * 2));
max-height: calc(100dvh - (var(--space-lg) * 2));
overflow: hidden;
background: var(--surface);
border: thin solid var(--border);
border-radius: var(--radius-lg);
/*
FNXC:FloatingWindow 2026-06-23-23:25:
Floating modals need a gentle, theme-controlled drop shadow. Use a local token with the app's existing shadow fallback instead of the undefined --shadow-xl so themes can soften, strengthen, or remove modal elevation intentionally.
*/
box-shadow: var(--floating-window-shadow, var(--shadow-lg));
color: var(--text);
resize: none;
pointer-events: auto;
}
/*
FNXC:FloatingWindow 2026-06-22-20:45:
Header is the drag handle. `touch-action: none` (matching the resize handles) hands the whole gesture to the pointer handlers so touch dragging stays smooth and never scrolls the page behind it. A comfortable min-height makes a forgiving touch target. `user-select: none` protects the drag from selecting header text.
*/
.floating-window__header {
display: flex;
flex-shrink: 0;
align-items: center;
gap: var(--space-sm);
min-height: 44px;
padding: var(--space-sm) var(--space-md);
border-bottom: thin solid var(--border);
background: var(--surface-2, var(--surface));
cursor: grab;
user-select: none;
touch-action: none;
}
.floating-window__header:active {
cursor: grabbing;
}
/*
FNXC:FloatingWindow 2026-06-22-12:20:
Headerless task pop-outs still need a visible drag affordance. The embedded task-detail modal header becomes the grab handle, matching the one-header "Open task" modal while preserving FloatingWindow's drag and resize behavior.
*/
.floating-window--headerless .floating-window__body {
overflow: hidden;
}
.floating-window--headerless .task-detail-content--embedded {
border-radius: inherit;
overflow: hidden;
}
.floating-window--headerless .task-detail-content--embedded > .modal-header {
cursor: grab;
user-select: none;
touch-action: none;
}
.floating-window--headerless .task-detail-content--embedded > .modal-header:active {
cursor: grabbing;
}
.floating-window--chat.floating-window--headerless .floating-window__body {
overflow: hidden;
}
.floating-window--chat .chat-view {
border-radius: inherit;
overflow: hidden;
}
/*
FNXC:ModalTouchGeometry 2026-07-26-12:19:
A bare `(pointer: coarse)` rule hid task-detail handles on every touch-primary device,
including tablets that must be movable and resizable. The JS tablet-touch discriminator
composes physical screen class, touch capability, and the 768px boundary, so phone sheets
and desktop hybrids retain their existing geometry while tablet touch gets active targets.
The shared scrollbar gutter stays in place: it keeps hosted scrollbars clear of east handles.
*/
.floating-window--touch-geometry {
overflow: visible;
}
/*
FNXC:ModalTouchGeometry 2026-07-26-12:19:
The painted borders remain mouse-sized, but tablet fingers receive FN-8602's shared
44px effective target outside the panel. Edges begin beyond corner targets so they do
not cover close controls, hosted footer actions, or scrollable content.
*/
.floating-window--touch-geometry .floating-window__resize-handle {
z-index: 3;
touch-action: none;
}
/*
FNXC:ModalTouchGeometry 2026-07-26-12:34:
A headerless window delegates dragging to a caller-owned header, so that resolved element must
receive the same tablet-only target floor as FloatingWindow's built-in header. `min-block-size`
enlarges only the interactive header box when necessary; it does not thicken resize borders or
extend an invisible target into hosted content, close controls, or footer actions.
*/
.floating-window--touch-geometry .floating-window__delegated-drag-handle {
box-sizing: border-box;
min-block-size: var(--modal-resize-touch-target);
cursor: grab;
user-select: none;
touch-action: none;
}
.floating-window--touch-geometry .floating-window__delegated-drag-handle:active {
cursor: grabbing;
}
.floating-window--touch-geometry .floating-window__resize-handle--n,
.floating-window--touch-geometry .floating-window__resize-handle--s {
left: var(--modal-resize-touch-target);
right: var(--modal-resize-touch-target);
height: var(--modal-resize-touch-target);
}
.floating-window--touch-geometry .floating-window__resize-handle--n {
top: calc((var(--modal-resize-touch-target) - var(--space-sm)) * -1);
}
.floating-window--touch-geometry .floating-window__resize-handle--s {
bottom: calc((var(--modal-resize-touch-target) - var(--space-sm)) * -1);
}
.floating-window--touch-geometry .floating-window__resize-handle--e,
.floating-window--touch-geometry .floating-window__resize-handle--w {
top: var(--modal-resize-touch-target);
bottom: var(--modal-resize-touch-target);
width: var(--modal-resize-touch-target);
}
.floating-window--touch-geometry .floating-window__resize-handle--e {
right: calc((var(--modal-resize-touch-target) - var(--space-sm)) * -1);
}
.floating-window--touch-geometry .floating-window__resize-handle--w {
left: calc((var(--modal-resize-touch-target) - var(--space-sm)) * -1);
}
.floating-window--touch-geometry .floating-window__resize-handle--ne,
.floating-window--touch-geometry .floating-window__resize-handle--nw,
.floating-window--touch-geometry .floating-window__resize-handle--se,
.floating-window--touch-geometry .floating-window__resize-handle--sw {
width: var(--modal-resize-touch-target);
height: var(--modal-resize-touch-target);
}
.floating-window--touch-geometry .floating-window__resize-handle--ne {
top: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1);
right: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1);
}
.floating-window--touch-geometry .floating-window__resize-handle--nw {
top: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1);
left: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1);
}
.floating-window--touch-geometry .floating-window__resize-handle--se {
bottom: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1);
right: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1);
}
.floating-window--touch-geometry .floating-window__resize-handle--sw {
bottom: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1);
left: calc((var(--modal-resize-touch-target) - var(--space-lg)) * -1);
}
/*
FNXC:ModalTouchGeometry 2026-07-26-12:19:
Phone sheets are strictly below 768px. At exactly 768px this query must not match so
`isTabletTouchViewport` can expose the active tablet touch targets without a cascade conflict.
FNXC:ChatModal 2026-06-22-14:49:
On mobile/narrow app viewports, opening Quick Chat should present the full Chat modal as a full-screen sheet instead of a small draggable desktop window. Scope this to the chat FloatingWindow and override the inline desktop geometry only at the mobile breakpoint; desktop pop-out behavior remains movable/resizable.
*/
@media (max-width: 767.98px) {
/*
FNXC:FloatingWindow 2026-07-12-17:35:
Mobile keeps the global `styles.css` pan-y lockdown so the dashboard cannot drift, but movable FloatingWindow headers must still resolve to an effective `touch-action: none`. Reassert the drag-handle contract at the mobile breakpoint, excluding full-screen sheet variants, so a single-finger header drag stays on the captured pointermove stream instead of being intersected back into page pan by the ancestor chain. Desktop drag/resize and mobile sheet variants are unchanged.
*/
.floating-window:not(.floating-window--chat):not(.floating-window--github-import-detail):not(.floating-window--task-detail):not(.floating-window--workflow-editor):not(.floating-window--automation):not(.floating-window--mission-interview):not(.floating-window--file-browser):not(.floating-window--pr-create):not(.artifacts-gallery-window) .floating-window__header {
touch-action: none;
}
.floating-window--chat {
inset: 0 !important;
width: 100vw !important;
height: 100dvh !important;
min-width: 0 !important;
min-height: 0 !important;
max-width: 100vw !important;
max-height: 100dvh !important;
border: none;
border-radius: 0;
box-shadow: none;
}
.floating-window--chat .floating-window__resize-handle {
display: none;
}
/*
FNXC:QuickAddAttachments 2026-07-16-00:00:
Pending task-attachment images open in a movable, resizable FloatingWindow on desktop. On narrow touch viewports the dedicated image-preview variant must instead fill the screen and remove desktop resize handles.
*/
.floating-window--image-preview {
inset: 0 !important;
width: 100vw !important;
height: 100dvh !important;
min-width: 0 !important;
min-height: 0 !important;
max-width: 100vw !important;
max-height: 100dvh !important;
border: none;
border-radius: 0;
box-shadow: none;
}
.floating-window--image-preview .floating-window__resize-handle {
display: none;
}
/*
FNXC:GitHubImport 2026-07-15-16:00:
Import details use FloatingWindow for desktop drag and resize; on mobile this scoped variant becomes a full-screen sheet
and hides desktop resize affordances.
*/
.floating-window--github-import-detail {
inset: 0 !important;
width: 100vw !important;
height: 100dvh !important;
min-width: 0 !important;
min-height: 0 !important;
max-width: 100vw !important;
max-height: 100dvh !important;
border: none;
border-radius: 0;
box-shadow: none;
}
.floating-window--github-import-detail .floating-window__resize-handle {
display: none;
}
.floating-window--chat .chat-view {
border-radius: 0;
}
/*
FNXC:MobileTaskPopups 2026-06-29-00:00:
Mobile board-card popup opens reuse the existing task-detail FloatingWindow, but the shell must behave like a full-screen mobile sheet. Scope the override to task-detail pop-outs, hide resize handles, and remove drag/resize cursors so no invisible desktop affordances remain on touch layouts.
*/
.floating-window--task-detail {
inset: 0 !important;
width: 100vw !important;
height: 100dvh !important;
min-width: 0 !important;
min-height: 0 !important;
max-width: 100vw !important;
max-height: 100dvh !important;
border: none;
border-radius: 0;
box-shadow: none;
}
.floating-window--task-detail .floating-window__resize-handle {
display: none;
}
/*
FNXC:MobileTaskPopups 2026-07-17-08:20:
FN-8015 reserves `margin-inline-end: var(--space-lg)` on the shared
`.floating-window__body` so a hosted scrollbar clears the desktop
east/north-east/south-east resize hot zones. The mobile task-detail sheet is
full-screen and hides every resize handle (above), so that gutter protects
nothing and instead shifts the whole panel left — leaving an uneven right
inset on every row. Zero it here so `.detail-body`'s own symmetric padding
defines both insets equally. Desktop resize-handle clearance is untouched
(this rule is mobile-only).
*/
.floating-window--task-detail .floating-window__body {
margin-inline-end: 0;
}
.floating-window--task-detail .task-detail-content--embedded,
.floating-window--task-detail .task-detail-content--embedded > .modal-header {
border-radius: 0;
cursor: default;
touch-action: auto;
}
}
/*
FNXC:FloatingWindow 2026-07-15-18:20:
Long titles (e.g. the GitHub import detail's "#2159 — <issue title>") must truncate with an ellipsis rather than hard-cut mid-word against the close button.
display MUST stay block, not flex: every caller passes a plain string, so under flex the text became an anonymous flex item, which text-overflow cannot act on — the ellipsis declared here silently never rendered. A block box makes the existing overflow/text-overflow/white-space trio work as written.
Reintroducing flex here (e.g. to lay out an element title) re-breaks truncation for all callers; give the title an inner element instead.
*/
.floating-window__title {
display: block;
flex: 1;
min-width: 0;
overflow: hidden;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.floating-window__close {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--space-xs);
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-muted);
cursor: pointer;
}
.floating-window__close:hover {
background: var(--status-todo-bg, var(--surface));
color: var(--text);
}
/*
FNXC:FloatingWindow 2026-06-22-20:45:
Body is a flex host that lets its single child stretch to the full panel width/height (min-width/min-height:0 so a wide child cannot collapse the flex line, and the child's own overflow can engage).
FNXC:FloatingWindow 2026-07-15-00:00:
FN-8015 / issue #2140 requires every shared FloatingWindow caller to keep its hosted vertical scrollbar clear of the right-edge and right-corner resize hot zones. Reserve the corner-width gutter on the body rather than specializing task detail: this applies equally when the body itself scrolls and when a headerless caller delegates scrolling to its full-size child. The panel border and its right handles remain available for desktop resizing, while the scrollbar is always inboard of the east, north-east, and south-east hit targets.
*/
.floating-window__body {
display: flex;
flex: 1;
min-width: 0;
min-height: 0;
margin-inline-end: var(--space-lg);
overflow: auto;
}
.floating-window__body > * {
flex: 1;
min-width: 0;
min-height: 0;
min-block-size: 0;
}
/*
FNXC:FloatingWindow 2026-06-22-20:45:
Edge + corner resize handles. touch-action:none keeps the drag from being hijacked by scroll/gestures so resizing stays smooth.
*/
.floating-window__resize-handle {
position: absolute;
z-index: 2;
touch-action: none;
}
.floating-window__resize-handle--n,
.floating-window__resize-handle--s {
left: var(--space-sm);
right: var(--space-sm);
height: var(--space-sm);
cursor: ns-resize;
}
.floating-window__resize-handle--n { top: 0; }
.floating-window__resize-handle--s { bottom: 0; }
.floating-window__resize-handle--e,
.floating-window__resize-handle--w {
top: var(--space-sm);
bottom: var(--space-sm);
width: var(--space-sm);
cursor: ew-resize;
}
.floating-window__resize-handle--e { right: 0; }
.floating-window__resize-handle--w { left: 0; }
.floating-window__resize-handle--ne,
.floating-window__resize-handle--nw,
.floating-window__resize-handle--se,
.floating-window__resize-handle--sw {
width: var(--space-lg);
height: var(--space-lg);
}
.floating-window__resize-handle--ne { top: 0; right: 0; cursor: nesw-resize; }
.floating-window__resize-handle--nw { top: 0; left: 0; cursor: nwse-resize; }
.floating-window__resize-handle--se { bottom: 0; right: 0; cursor: nwse-resize; }
.floating-window__resize-handle--sw { bottom: 0; left: 0; cursor: nesw-resize; }