fix: even out tablet popup right inset; drag terminal from whole toolbar

Third recurrence of the Task Detail right-padding bug (FN-8630/FN-8634): those
fixes only covered the .modal-overlay shells, while every tablet task popup and
floating terminal renders through FloatingWindow, whose shared body carries
FN-8015's margin-inline-end scrollbar gutter — a 16px right border with a 0px
left one. Tablet-mode windows (.floating-window--tablet, keyed on viewport MODE
so non-touch tablet widths match too) now zero the gutter; touch never grabs
scrollbar thumbs, so the desktop hot-zone conflict FN-8015 solves cannot occur.
GitHub-import's detail panel, which used the gutter as its right inset,
compensates locally. Desktop keeps the FN-8015 contract.

Also per operator request: the floating terminal is draggable from the empty
strip space behind the tabs and anywhere in the top toolbar, not only the
FN-8633 grip. Tab presses keep stopPropagation (scoped to .terminal-tab) so
they never start a window drag; the tablet floating header supersedes the
pan-x contract with touch-action: none (an overflowing strip is replaced by
the mobile-tabs dropdown, so no visible strip pans horizontally).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-31 20:54:48 -07:00
parent b9612de748
commit 71576d9536
8 changed files with 164 additions and 21 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix uneven right padding on tablet task/terminal popups; drag the terminal from anywhere in its toolbar.
category: fix
dev: Tablet-mode FloatingWindows (`.floating-window--tablet`) zero the FN-8015 scrollbar gutter; GitHub-import detail compensates locally. Terminal tab-strip empty space now bubbles to the `.terminal-header` drag handle (`touch-action: none` on the tablet floating header supersedes the FN-8633 pan-x contract).

View File

@@ -539,6 +539,22 @@ FN-8015 / issue #2140 requires every shared FloatingWindow caller to keep its ho
overflow: auto; overflow: auto;
} }
/*
FNXC:ModalTouchGeometry 2026-08-01-03:48:
Tablet windows drop FN-8015's inline-end scrollbar gutter. On tablet the gutter reads as a
too-big, uneven right border inside Task Detail popups and the floating terminal (the inner
surfaces already carry their own symmetric padding), and the mouse conflict FN-8015 solves —
grabbing a hosted scrollbar thumb versus the east resize hot zone — does not apply to touch
input, which never grabs scrollbar thumbs. This keys on tablet MODE (`.floating-window--tablet`,
set from `useViewportMode()`), not `--touch-geometry`, so a non-touch window at tablet widths
gets the same symmetric inset the operator sees on an iPad. Desktop keeps the FN-8015 gutter.
Callers that relied on the gutter as their right inset must compensate under the same class
(see `.github-import-detail-panel`).
*/
.floating-window--tablet .floating-window__body {
margin-inline-end: 0;
}
.floating-window__body > * { .floating-window__body > * {
flex: 1; flex: 1;
min-width: 0; min-width: 0;

View File

@@ -232,6 +232,16 @@ export function FloatingWindow({
mouse geometry; a known touch tablet at 768px is the one surface that receives enlarged targets. mouse geometry; a known touch tablet at 768px is the one surface that receives enlarged targets.
*/ */
const hasTabletTouchGeometry = isTabletTouchViewport(viewportMode); const hasTabletTouchGeometry = isTabletTouchViewport(viewportMode);
/*
FNXC:ModalTouchGeometry 2026-08-01-03:48:
Tablet MODE (touch or not) is a distinct styling surface from touch geometry: a 900px
non-touch window classifies tablet without `--touch-geometry`, yet operators still see the
FN-8015 scrollbar gutter as an uneven right inset there (third recurrence of the Task Detail
right-padding bug — FN-8630/FN-8634 fixed only the `.modal-overlay` shells, while every
tablet task popup and floating terminal renders through THIS host). Expose the mode as a
class so FloatingWindow.css can zero the gutter for all tablet windows.
*/
const isTabletMode = viewportMode === "tablet";
const initialGeometry = useRef<{ size: FloatingWindowSize; position: FloatingWindowPosition } | null>(null); const initialGeometry = useRef<{ size: FloatingWindowSize; position: FloatingWindowPosition } | null>(null);
/* /*
FNXC:ModalGeometryPersistence 2026-07-16-00:40: FNXC:ModalGeometryPersistence 2026-07-16-00:40:
@@ -638,7 +648,7 @@ export function FloatingWindow({
> >
<div <div
ref={panelRef} ref={panelRef}
className={`floating-window${hideHeader ? " floating-window--headerless" : ""}${hasTabletTouchGeometry ? " floating-window--touch-geometry" : ""}${className ? ` ${className}` : ""}`} className={`floating-window${hideHeader ? " floating-window--headerless" : ""}${hasTabletTouchGeometry ? " floating-window--touch-geometry" : ""}${isTabletMode ? " floating-window--tablet" : ""}${className ? ` ${className}` : ""}`}
style={panelStyle} style={panelStyle}
data-testid={`floating-window-${windowKey}`} data-testid={`floating-window-${windowKey}`}
onPointerDownCapture={bringToFront} onPointerDownCapture={bringToFront}

View File

@@ -327,6 +327,16 @@ Padding lives on the panel rather than the pane content so header, toast, and sc
overflow: hidden; overflow: hidden;
} }
/*
FNXC:GitHubImport 2026-08-01-03:48:
Tablet windows zero the shared FN-8015 body gutter (`.floating-window--tablet .floating-window__body`
in FloatingWindow.css) because it rendered as an uneven right border on tablet. This panel's right
inset was supplied by that gutter, so restore it locally to keep the panel's insets symmetric.
*/
.floating-window--tablet .github-import-detail-panel {
padding-inline-end: var(--space-lg);
}
/* /*
FNXC:GitHubImport 2026-07-15-23:25: FNXC:GitHubImport 2026-07-15-23:25:
Bottom action bar for the detail preview. Import/Close issue moved here from the pane header — Bottom action bar for the detail preview. Import/Close issue moved here from the pane header —

View File

@@ -244,9 +244,12 @@ FN-7897: reserve space for the fixed ExecutorStatusBar footer when it is rendere
/* /*
FNXC:TerminalModalControls 2026-07-31-22:41: FNXC:TerminalModalControls 2026-07-31-22:41:
The tablet classifier, rather than the legacy 769px media query, owns this grip so a known The tablet classifier, rather than the legacy 769px media query, owns this grip so a known
768px touch tablet remains a floating window instead of receiving phone-sheet chrome. Only the 768px touch tablet remains a floating window instead of receiving phone-sheet chrome.
reserved grip disables browser gesture handling: applying `touch-action: none` to the panel or
header prevents native horizontal panning in the descendant overflowing tab strip. FNXC:TerminalModalControls 2026-08-01-03:48:
The grip is no longer the sole drag surface — the whole tablet floating header (including empty
tab-strip space) is `touch-action: none` and draggable below — but it stays as the always-visible
affordance that signals the window is movable.
*/ */
.modal.terminal-modal.terminal-modal--tablet.terminal-modal--floating .terminal-header__drag-grip { .modal.terminal-modal.terminal-modal--tablet.terminal-modal--floating .terminal-header__drag-grip {
position: relative; position: relative;
@@ -274,12 +277,19 @@ header prevents native horizontal panning in the descendant overflowing tab stri
} }
/* /*
FNXC:TerminalModalControls 2026-07-31-22:41: FNXC:TerminalModalControls 2026-08-01-03:48:
The tab strip must retain native horizontal touch panning while the sibling grip owns window Supersedes the 2026-07-31 pan-x contract: the operator wants the floating terminal draggable
movement. This explicit contract prevents future tablet drag rules from suppressing tab scrolling. from the empty strip space behind the tabs and anywhere in the top toolbar, not only the grip.
`touch-action: none` on the tablet floating header (and its strip) hands the whole touch gesture
to FloatingWindow's pointer drag; without it the browser claims horizontal movement as a native
pan and pointercancels the drag mid-gesture. Native strip panning is not lost: an overflowing
strip is replaced by the `.terminal-mobile-tabs` dropdown (ResizeObserver in TerminalModal.tsx),
so a visible strip never scrolls horizontally. Tab presses stay tab presses via the scoped
stopPropagation in renderTerminalTabStrip.
*/ */
.modal.terminal-modal.terminal-modal--tablet.terminal-modal--floating .terminal-header,
.modal.terminal-modal.terminal-modal--tablet.terminal-modal--floating .terminal-tabs { .modal.terminal-modal.terminal-modal--tablet.terminal-modal--floating .terminal-tabs {
touch-action: pan-x; touch-action: none;
} }
/* /*
@@ -337,6 +347,17 @@ hit area; it is not a second terminal control.
border-bottom-color: var(--in-progress); border-bottom-color: var(--in-progress);
} }
/*
FNXC:TerminalModalControls 2026-08-01-03:48:
The floating header (including empty tab-strip space) is a window drag surface at every pointer
type; show the grab affordance there while real tab surfaces keep the base `.terminal-tab`
pointer cursor above.
*/
.modal.terminal-modal.terminal-modal--floating .terminal-header,
.modal.terminal-modal.terminal-modal--floating .terminal-tabs {
cursor: grab;
}
.terminal-tab-indicator { .terminal-tab-indicator {
position: absolute; position: absolute;
bottom: 0; bottom: 0;

View File

@@ -2427,9 +2427,14 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
); );
/* /*
FNXC:TerminalModalControls 2026-07-31-22:19: FNXC:TerminalModalControls 2026-08-01-03:48:
The tab scroller owns tab presses and horizontal panning; floating-window drags begin only on The floating terminal must be movable by dragging the empty strip space behind the tabs and
the reserved grip so a tab interaction cannot leave a captured header drag behind. anywhere else in the top toolbar, not only the reserved grip (operator request following
FN-8633). Only a press that starts inside a real tab surface (`.terminal-tab`, which includes
the close and new-tab buttons) stays a tab interaction; empty strip space bubbles to the
FloatingWindow `.terminal-header` delegated drag handle. Tab presses keep stopPropagation so
they can never leave a captured header drag behind, and an overflowing strip is replaced by
the `.terminal-mobile-tabs` dropdown, so no visible strip ever needs horizontal panning.
*/ */
const renderTerminalTabStrip = (measuring = false) => ( const renderTerminalTabStrip = (measuring = false) => (
<div <div
@@ -2437,7 +2442,10 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
className={`terminal-tabs${measuring ? " terminal-tabs--measuring" : ""}`} className={`terminal-tabs${measuring ? " terminal-tabs--measuring" : ""}`}
data-testid={measuring ? "terminal-tabs-measuring" : "terminal-tabs"} data-testid={measuring ? "terminal-tabs-measuring" : "terminal-tabs"}
aria-hidden={measuring || undefined} aria-hidden={measuring || undefined}
onPointerDown={measuring ? undefined : (event) => event.stopPropagation()} onPointerDown={measuring ? undefined : (event) => {
const target = event.target as HTMLElement | null;
if (target?.closest(".terminal-tab")) event.stopPropagation();
}}
> >
{tabs.map((tab) => ( {tabs.map((tab) => (
<div <div

View File

@@ -199,6 +199,28 @@ describe("FloatingWindow", () => {
expect(mobileTaskDetailBody).toContain("margin-inline-end: 0;"); expect(mobileTaskDetailBody).toContain("margin-inline-end: 0;");
expect(cssRulesForClass(desktopAppCss, "floating-window--task-detail").some((rule) => rule.includes("floating-window__body"))).toBe(false); expect(cssRulesForClass(desktopAppCss, "floating-window--task-detail").some((rule) => rule.includes("floating-window__body"))).toBe(false);
/*
FNXC:ModalTouchGeometry 2026-08-01-03:48:
Tablet contract: ALL tablet-mode floating windows zero the FN-8015 gutter (it read as a
too-big uneven right border in Task Detail popups and the floating terminal — third
recurrence of the right-padding bug because tablet renders through FloatingWindow, not
`.modal-overlay`). Touch never grabs scrollbar thumbs, so the desktop hot-zone conflict
does not apply. GitHubImport relied on the gutter as its right inset and must compensate
under the same class.
*/
const tabletBody = cssRuleContaining(
floatingWindowCss,
".floating-window--tablet .floating-window__body",
"margin-inline-end",
);
expect(tabletBody).toContain("margin-inline-end: 0;");
const tabletGitHubImportPanel = cssRuleContaining(
allAppCss,
".floating-window--tablet .github-import-detail-panel",
"padding-inline-end",
);
expect(tabletGitHubImportPanel).toContain("padding-inline-end: var(--space-lg);");
// Headerless and chat variants replace only body overflow; the inherited gutter remains intact for their inner scrollers. // Headerless and chat variants replace only body overflow; the inherited gutter remains intact for their inner scrollers.
expect(cssRuleFor(floatingWindowCss, ".floating-window--headerless .floating-window__body")).toContain("overflow: hidden;"); expect(cssRuleFor(floatingWindowCss, ".floating-window--headerless .floating-window__body")).toContain("overflow: hidden;");
expect(cssRuleFor(floatingWindowCss, ".floating-window--chat.floating-window--headerless .floating-window__body")).toContain("overflow: hidden;"); expect(cssRuleFor(floatingWindowCss, ".floating-window--chat.floating-window--headerless .floating-window__body")).toContain("overflow: hidden;");
@@ -226,6 +248,32 @@ describe("FloatingWindow", () => {
expect(cssRuleFor(phoneBlock, ".floating-window--task-detail .floating-window__resize-handle")).toContain("display: none;"); expect(cssRuleFor(phoneBlock, ".floating-window--task-detail .floating-window__resize-handle")).toContain("display: none;");
}); });
/*
FNXC:ModalTouchGeometry 2026-08-01-03:48:
Tablet MODE is its own styling marker, distinct from `--touch-geometry`: a non-touch window
at tablet widths must still receive `floating-window--tablet` so the FN-8015 gutter zeroing
applies everywhere the app classifies the viewport as tablet.
*/
it("marks tablet-mode windows with floating-window--tablet even without touch", () => {
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
matches: query === "(min-width: 769px) and (max-width: 1024px)",
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})));
render(
<FloatingWindow windowKey="tablet-mode" title="Tablet" onClose={() => {}}>
<div>tablet body</div>
</FloatingWindow>
);
const panel = screen.getByTestId("floating-window-tablet-mode");
expect(panel.className).toContain("floating-window--tablet");
});
it("keeps task-detail long content clear of right handles while preserving short-content right-edge resize", () => { it("keeps task-detail long content clear of right handles while preserving short-content right-edge resize", () => {
const longContent = Array.from({ length: 40 }, (_, index) => <p key={index}>Scrollable task detail {index}</p>); const longContent = Array.from({ length: 40 }, (_, index) => <p key={index}>Scrollable task detail {index}</p>);
const { unmount } = render( const { unmount } = render(

View File

@@ -1458,6 +1458,22 @@ describe("TerminalModal", () => {
fireEvent.click(screen.getAllByRole("tab")[1]); fireEvent.click(screen.getAllByRole("tab")[1]);
expect(setActiveTab).toHaveBeenCalledWith("tab-2"); expect(setActiveTab).toHaveBeenCalledWith("tab-2");
expect(panel.setPointerCapture).toHaveBeenCalledTimes(1); expect(panel.setPointerCapture).toHaveBeenCalledTimes(1);
/*
FNXC:TerminalModalControls 2026-08-01-03:48:
Empty strip space behind the tabs is a drag surface: a press that starts on the
`.terminal-tabs` container itself (not inside a `.terminal-tab`) must bubble to the
FloatingWindow `.terminal-header` delegated drag handle and move the window.
*/
const tabStrip = screen.getByTestId("terminal-tabs");
const preDragLeft = panel.style.left;
fireEvent.pointerDown(tabStrip, { pointerId: 65, pointerType: "touch", clientX: 300, clientY: 40 });
fireEvent.pointerMove(panel, { pointerId: 65, pointerType: "touch", clientX: 380, clientY: 90 });
fireEvent.pointerUp(panel, { pointerId: 65, pointerType: "touch", clientX: 380, clientY: 90 });
await waitFor(() => {
expect(panel.setPointerCapture).toHaveBeenCalledTimes(2);
expect(panel.style.left).not.toBe(preDragLeft);
});
unmount(); unmount();
} finally { } finally {
Object.defineProperty(window, "innerWidth", { configurable: true, value: previousInnerWidth }); Object.defineProperty(window, "innerWidth", { configurable: true, value: previousInnerWidth });
@@ -1467,24 +1483,31 @@ describe("TerminalModal", () => {
} }
}); });
it("keeps the tablet drag grip isolated from the horizontally pannable tab strip", () => { /*
FNXC:TerminalModalControls 2026-08-01-03:48:
Supersedes the FN-8633 grip-isolation contract: the operator wants the floating terminal
draggable from the empty strip space behind the tabs and anywhere in the top toolbar. The
tablet floating header AND tab strip hand the whole touch gesture to FloatingWindow's pointer
drag (`touch-action: none`); native strip panning is moot because an overflowing strip is
replaced by the mobile-tabs dropdown. The grip remains as the visible movability affordance.
*/
it("makes the whole tablet floating header a touch drag surface while keeping the grip affordance", () => {
const gripSelector = ".modal.terminal-modal.terminal-modal--tablet.terminal-modal--floating .terminal-header__drag-grip"; const gripSelector = ".modal.terminal-modal.terminal-modal--tablet.terminal-modal--floating .terminal-header__drag-grip";
const tabsSelector = ".modal.terminal-modal.terminal-modal--tablet.terminal-modal--floating .terminal-tabs";
const gripSelectorIndex = terminalModalCss.indexOf(gripSelector); const gripSelectorIndex = terminalModalCss.indexOf(gripSelector);
const gripRuleEnd = terminalModalCss.indexOf("}", gripSelectorIndex); const gripRuleEnd = terminalModalCss.indexOf("}", gripSelectorIndex);
const gripRule = terminalModalCss.slice(gripSelectorIndex, gripRuleEnd); const gripRule = terminalModalCss.slice(gripSelectorIndex, gripRuleEnd);
const tabsSelectorIndex = terminalModalCss.indexOf(tabsSelector); const headerTouchSelector = ".modal.terminal-modal.terminal-modal--tablet.terminal-modal--floating .terminal-header,\n.modal.terminal-modal.terminal-modal--tablet.terminal-modal--floating .terminal-tabs";
const tabsRuleEnd = terminalModalCss.indexOf("}", tabsSelectorIndex); const headerTouchIndex = terminalModalCss.indexOf(headerTouchSelector);
const tabsRule = terminalModalCss.slice(tabsSelectorIndex, tabsRuleEnd); const headerTouchRule = terminalModalCss.slice(headerTouchIndex, terminalModalCss.indexOf("}", headerTouchIndex));
expect(gripSelectorIndex).toBeGreaterThan(-1); expect(gripSelectorIndex).toBeGreaterThan(-1);
expect(gripRule).toContain("min-block-size: var(--modal-resize-touch-target);"); expect(gripRule).toContain("min-block-size: var(--modal-resize-touch-target);");
expect(gripRule).toContain("min-inline-size: var(--modal-resize-touch-target);"); expect(gripRule).toContain("min-inline-size: var(--modal-resize-touch-target);");
expect(gripRule).toContain("touch-action: none;"); expect(gripRule).toContain("touch-action: none;");
expect(tabsSelectorIndex).toBeGreaterThan(-1); expect(headerTouchIndex).toBeGreaterThan(-1);
expect(tabsRule).toContain("touch-action: pan-x;"); expect(headerTouchRule).toContain("touch-action: none;");
expect(terminalModalCss).not.toContain(".modal.terminal-modal.terminal-modal--tablet.terminal-modal--floating {\n touch-action: none;"); // Grab affordance on the floating header/strip; real tabs keep the base pointer cursor.
expect(terminalModalCss).not.toContain(".modal.terminal-modal.terminal-modal--tablet.terminal-modal--floating .terminal-header {\n touch-action: none;"); expect(terminalModalCss).toContain(".modal.terminal-modal.terminal-modal--floating .terminal-header,\n.modal.terminal-modal.terminal-modal--floating .terminal-tabs {\n cursor: grab;");
expect(terminalModalCss.slice(0, gripSelectorIndex)).not.toContain("@media (min-width: 769px)"); expect(terminalModalCss.slice(0, gripSelectorIndex)).not.toContain("@media (min-width: 769px)");
expect(loadAllAppCss()).toContain(gripSelector); expect(loadAllAppCss()).toContain(gripSelector);
}); });