From 167d242ad540b68a895d474d4f45b47ea9371ab4 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 29 Jun 2026 14:50:02 -0700 Subject: [PATCH] FN-7236: make workflow tabs swipeable on mobile Keep the Workflow simple-editor tab bar contained while preserving horizontal touch scrolling. - Clamp the mobile tab strip to the editor width and make it the horizontal scroll owner. - Preserve intrinsic tab button widths and pan gestures for narrow touch viewports. - Add CSS, component, and shared overflow regression coverage for the full six-tab strip. - Add a patch changeset for the published Fusion package. Files changed: .changeset/fn-7236-workflow-mobile-tab-scroll.md | 7 +++ .../dashboard-overflow-containment.test.tsx | 32 ++++++++++++- .../app/components/WorkflowNodeEditor.css | 32 ++++++++++++- .../__tests__/WorkflowNodeEditor.css.test.ts | 29 ++++++++++++ .../__tests__/WorkflowNodeEditor.test.tsx | 55 +++++++++++++++++++++- 5 files changed, 152 insertions(+), 3 deletions(-) Fusion-Task-Id: FN-7236 Fusion-Task-Lineage: 1601d4c2-2039-4410-809c-7dcdfea04301 Co-authored-by: Fusion (runfusion.ai) --- .../fn-7236-workflow-mobile-tab-scroll.md | 7 +++ .../dashboard-overflow-containment.test.tsx | 32 ++++++++++- .../app/components/WorkflowNodeEditor.css | 32 ++++++++++- .../__tests__/WorkflowNodeEditor.css.test.ts | 29 ++++++++++ .../__tests__/WorkflowNodeEditor.test.tsx | 55 ++++++++++++++++++- 5 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 .changeset/fn-7236-workflow-mobile-tab-scroll.md diff --git a/.changeset/fn-7236-workflow-mobile-tab-scroll.md b/.changeset/fn-7236-workflow-mobile-tab-scroll.md new file mode 100644 index 0000000000..d68f954119 --- /dev/null +++ b/.changeset/fn-7236-workflow-mobile-tab-scroll.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Keep Workflow simple-editor tabs reachable on mobile. +category: fix +dev: Makes the simple editor tab strip horizontally pannable on narrow touch viewports. diff --git a/packages/dashboard/app/__tests__/dashboard-overflow-containment.test.tsx b/packages/dashboard/app/__tests__/dashboard-overflow-containment.test.tsx index c3d822ccd5..734a17d865 100644 --- a/packages/dashboard/app/__tests__/dashboard-overflow-containment.test.tsx +++ b/packages/dashboard/app/__tests__/dashboard-overflow-containment.test.tsx @@ -203,7 +203,11 @@ function WorkflowFixture({ simple }: { simple: boolean }) {
@@ -330,7 +334,18 @@ describe("dashboard overflow containment shared mobile/tablet net (FN-6385)", () expect(mobileCanvasBlock).toContain("overflow: hidden;"); expect(mobileShellBlock).toContain("overflow: hidden;"); expect(simpleShellBlock).toContain("overflow: hidden;"); + expect(simpleTabsBlock).toContain("width: 100%;"); + expect(simpleTabsBlock).toContain("max-inline-size: 100%;"); + expect(simpleTabsBlock).toContain("min-width: 0;"); expect(simpleTabsBlock).toContain("overflow-x: auto;"); + expect(simpleTabsBlock).toContain("overflow-y: hidden;"); + expect(simpleTabsBlock).toContain("overscroll-behavior-inline: contain;"); + expect(simpleTabsBlock).toContain("touch-action: pan-x pan-y;"); + expect(simpleTabsBlock).toContain("-webkit-overflow-scrolling: touch;"); + const simpleTabBlock = ruleBlock(baseCss, ".wf-mobile-tab"); + expect(simpleTabBlock).toContain("flex: 0 0 auto;"); + expect(simpleTabBlock).toContain("min-width: max-content;"); + expect(simpleTabBlock).toContain("touch-action: pan-x pan-y;"); }); it("resolves viewport helper modes for mobile, tablet, and landscape-phone breakpoints", () => { @@ -409,11 +424,26 @@ describe("dashboard overflow containment shared mobile/tablet net (FN-6385)", () assertInViewport(within(surface).getByRole("button", { name: /close workflow editor/i }), viewport, "workflow close action"); } + const simpleWorkflow = screen.getByTestId("simple-workflow"); + const simpleBody = simpleWorkflow.querySelector(".wf-editor-body"); + const simpleShell = simpleWorkflow.querySelector(".wf-mobile-shell"); const tabStrip = screen.getAllByRole("navigation", { name: /workflow editor sections/i })[1]; + expect(simpleBody).not.toBeNull(); + expect(simpleShell).not.toBeNull(); + defineMetric(simpleBody!, "clientWidth", viewport.width); + defineMetric(simpleBody!, "scrollWidth", viewport.width); + defineMetric(simpleShell!, "clientWidth", viewport.width); + defineMetric(simpleShell!, "scrollWidth", viewport.width); defineMetric(tabStrip, "clientWidth", viewport.width); defineMetric(tabStrip, "scrollWidth", viewport.width * 2); + const tabButtons = within(tabStrip).getAllByRole("button"); + // FNXC:WorkflowSimpleEditor 2026-06-29-15:42: The shared overflow net must prove the complete six-tab simple-editor strip owns horizontal scroll while its editor body/shell remain contained at both mobile and tablet breakpoints. + expect(tabButtons.map((button) => button.textContent)).toEqual(["Graph", "Add", "Settings", "Fields", "Columns", "Actions"]); expect(ruleBlock(baseCss, ".wf-mobile-tabs")).toContain("overflow-x: auto;"); expect(tabStrip.scrollWidth).toBeGreaterThan(tabStrip.clientWidth); + assertContained(simpleShell!, `${viewport.name} simple workflow shell`); + assertContained(simpleBody!, `${viewport.name} simple workflow body`); + expect(simpleWorkflow.scrollWidth).toBeLessThanOrEqual(simpleWorkflow.clientWidth + 1); assertNoDocumentHorizontalOverflow(`${viewport.name} workflow root`); }); diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.css b/packages/dashboard/app/components/WorkflowNodeEditor.css index fae25da4be..9a7dfcd7bc 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.css +++ b/packages/dashboard/app/components/WorkflowNodeEditor.css @@ -836,18 +836,48 @@ Flow's SVG attributes and makes the graph preview read blank. overflow: hidden; } +/* +FNXC:WorkflowSimpleEditor 2026-06-29-13:20: +Mobile simple-editor tabs must stay inside the editor width while remaining horizontally swipeable/reachable. The tab strip owns horizontal overflow, and both the scroller and tab button touch targets opt into pan-x because the global mobile reset applies `touch-action: pan-y` per element, not by inheritance. + +FNXC:WorkflowSimpleEditor 2026-06-29-13:32: +Keep the scroller border-box explicit so the strip's padding and border are included in its 100% inline size even if future CSS narrows the global reset. + +FNXC:WorkflowSimpleEditor 2026-06-29-16:22: +Use both physical and logical width clamps because this tab strip is reused by automatic mobile simple layout and compact simple layout. The scroller may overflow internally, but its own border box must stay pinned to the editor width so the page never expands. + +FNXC:WorkflowSimpleEditor 2026-06-29-16:48: +Keep logical overflow and tab intrinsic-size declarations beside the physical fallbacks so the simple-editor tab strip remains the only horizontal scroll owner while tab-button touches can start pan-x gestures under the global mobile `touch-action: pan-y` reset. +*/ .wf-mobile-tabs { + box-sizing: border-box; display: flex; flex: 0 0 auto; + width: 100%; + inline-size: 100%; + max-width: 100%; + max-inline-size: 100%; + min-width: 0; + min-inline-size: 0; gap: var(--space-xs); padding: var(--space-sm); overflow-x: auto; - overflow-y: visible; + overflow-y: hidden; + overflow-inline: auto; + overflow-block: hidden; + overscroll-behavior-inline: contain; + touch-action: pan-x pan-y; + -webkit-overflow-scrolling: touch; border-bottom: 1px solid var(--border); } .wf-mobile-tab { + box-sizing: border-box; flex: 0 0 auto; + min-width: max-content; + min-inline-size: max-content; + white-space: nowrap; + touch-action: pan-x pan-y; min-height: var(--wf-editor-touch-target); padding: var(--space-sm) var(--space-md); border: 1px solid var(--border); diff --git a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.css.test.ts b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.css.test.ts index e1db48aeea..ce03070111 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.css.test.ts +++ b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.css.test.ts @@ -284,6 +284,35 @@ describe("WorkflowNodeEditor mobile CSS contract", () => { expect(collapsedToggleRule).toMatch(/bottom\s*:\s*var\(--space-sm\)\s*;/); }); + it("FN-7236 keeps simple-editor tabs as the touch-pannable horizontal scroller", () => { + const editorCss = readComponentCss("WorkflowNodeEditor.css"); + + // Surface Enumeration: the same .wf-mobile-tabs/.wf-mobile-tab rules render for mobile automatic simple layout and desktop/tablet compact simple layout because they intentionally live outside the mobile media query. + const tabsRule = findRule([editorCss], /\.wf-mobile-tabs\s*\{[^}]*\}/); + expect(tabsRule).toMatch(/box-sizing\s*:\s*border-box\s*;/); + expect(tabsRule).toMatch(/display\s*:\s*flex\s*;/); + expect(tabsRule).toMatch(/flex\s*:\s*0 0 auto\s*;/); + expect(tabsRule).toMatch(/width\s*:\s*100%\s*;/); + expect(tabsRule).toMatch(/inline-size\s*:\s*100%\s*;/); + expect(tabsRule).toMatch(/max-width\s*:\s*100%\s*;/); + expect(tabsRule).toMatch(/max-inline-size\s*:\s*100%\s*;/); + expect(tabsRule).toMatch(/min-width\s*:\s*0\s*;/); + expect(tabsRule).toMatch(/min-inline-size\s*:\s*0\s*;/); + expect(tabsRule).toMatch(/overflow-x\s*:\s*auto\s*;/); + expect(tabsRule).toMatch(/overflow-y\s*:\s*hidden\s*;/); + expect(tabsRule).toMatch(/overscroll-behavior-inline\s*:\s*contain\s*;/); + expect(tabsRule).toMatch(/touch-action\s*:\s*pan-x pan-y\s*;/); + expect(tabsRule).toMatch(/-webkit-overflow-scrolling\s*:\s*touch\s*;/); + + const tabRule = findRule([editorCss], /\.wf-mobile-tab\s*\{[^}]*\}/); + expect(tabRule).toMatch(/box-sizing\s*:\s*border-box\s*;/); + expect(tabRule).toMatch(/flex\s*:\s*0 0 auto\s*;/); + expect(tabRule).toMatch(/min-width\s*:\s*max-content\s*;/); + expect(tabRule).toMatch(/white-space\s*:\s*nowrap\s*;/); + expect(tabRule).toMatch(/touch-action\s*:\s*pan-x pan-y\s*;/); + expect(tabRule).toMatch(/min-height\s*:\s*var\(--wf-editor-touch-target\)\s*;/); + }); + it("FN-6034 keeps the desktop graph canvas shrinkable while FloatingWindow owns the modal minimum", () => { const baseCss = loadAllAppCssBaseOnly(); diff --git a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx index 5048d40062..85fd7e9b4b 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx @@ -114,6 +114,39 @@ function getPromptFullscreenTextarea() { return within(overlay!).getByLabelText("Prompt") as HTMLTextAreaElement; } +function defineElementMetric(element: Element, property: "clientWidth" | "scrollWidth", value: number) { + Object.defineProperty(element, property, { configurable: true, value }); +} + +function assertSimpleEditorTabScrollOwner(shell: HTMLElement, width: number) { + const tabStrip = within(shell).getByRole("navigation", { name: /workflow editor sections/i }); + const editorBody = shell.closest(".wf-editor-body"); + const editorModal = shell.closest(".wf-editor-modal"); + expect(editorBody).not.toBeNull(); + expect(editorModal).not.toBeNull(); + + defineElementMetric(tabStrip, "clientWidth", width); + defineElementMetric(tabStrip, "scrollWidth", width * 2); + for (const containedElement of [shell, editorBody!, editorModal!, document.documentElement, document.body]) { + defineElementMetric(containedElement, "clientWidth", width); + defineElementMetric(containedElement, "scrollWidth", width); + } + + const tabButtons = within(tabStrip).getAllByRole("button"); + // FNXC:WorkflowSimpleEditor 2026-06-29-13:16: The regression invariant is the complete six-tab simple-editor strip; do not let a test pass by measuring a reduced or renamed tab set that hides overflow instead of preserving horizontal scroll. + expect(tabButtons.map((button) => button.textContent)).toEqual(["Graph", "Add", "Settings", "Fields", "Columns", "Actions"]); + expect(tabStrip).toHaveClass("wf-mobile-tabs"); + for (const tabButton of tabButtons) { + expect(tabButton).toHaveClass("wf-mobile-tab"); + } + expect(tabStrip.scrollWidth).toBeGreaterThan(tabStrip.clientWidth); + expect(shell.scrollWidth).toBeLessThanOrEqual(shell.clientWidth + 1); + expect(editorBody!.scrollWidth).toBeLessThanOrEqual(editorBody!.clientWidth + 1); + expect(editorModal!.scrollWidth).toBeLessThanOrEqual(editorModal!.clientWidth + 1); + expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(document.documentElement.clientWidth + 1); + expect(document.body.scrollWidth).toBeLessThanOrEqual(document.body.clientWidth + 1); +} + function mockWorkflowEditorViewport(mode: "desktop" | "mobile" | "tablet" = "desktop") { Object.defineProperty(window, "matchMedia", { writable: true, @@ -404,9 +437,13 @@ describe("workflow-flow-mapping", () => { it("preserves duplicate and parallel built-in edges with valid endpoints and hit targets", () => { const { edges } = edgeRenderableAssertion(builtinDef()); const failuresToEnd = edges.filter((edge) => edge.target === "end" && edge.data?.condition === "failure"); + // FNXC:WorkflowOptionalGroup 2026-06-21-15:30: the coding built-in's pre-merge `workflow-step` seam was migrated to a `browser-verification` optional-group (U6), which now carries the failure->end edge in its place. + // FNXC:CodeReviewStep 2026-06-25-00:00: the default-on `code-review` optional-group is also on the pre-merge success path with its own failure->end edge (see builtin-code-review-group.test.ts), so it is an expected failure->end source too. This corrected a stale assertion that predated the code-review group's addition. + // FNXC:WorkflowPlanReview 2026-06-29-00:00: the built-in coding workflow now includes a `plan-review` gate on the normal path; its failure edge must remain renderable and independently clickable like the existing parallel failure-to-end edges. expect(failuresToEnd.map((edge) => edge.source).sort()).toEqual([ "execute", "merge-attempt", + "plan-review", "planning", "review", ]); @@ -565,6 +602,7 @@ describe("WorkflowNodeEditor", () => { for (const panel of ["graph", "add", "settings", "fields", "columns", "actions"]) { expect(within(shell).getByTestId(`wf-mobile-tab-${panel}`)).toBeInTheDocument(); } + assertSimpleEditorTabScrollOwner(shell, 1024); fireEvent.click(screen.getByTestId("wf-mobile-tab-actions")); expect(screen.getByTestId("wf-mobile-save")).toBeInTheDocument(); @@ -579,6 +617,19 @@ describe("WorkflowNodeEditor", () => { expect(screen.getByTestId("wf-mobile-add-gate-gate")).toBeInTheDocument(); }); + it("renders automatic mobile simple layout with the tab strip as scroll owner", async () => { + mockWorkflowEditorViewport("mobile"); + vi.mocked(fetchWorkflows).mockResolvedValue([def()]); + + render( {}} addToast={() => {}} />); + + fireEvent.click(await screen.findByRole("button", { name: "QA" })); + + const shell = await screen.findByTestId("wf-mobile-shell"); + expect(screen.queryByTestId("wf-layout-toggle")).not.toBeInTheDocument(); + assertSimpleEditorTabScrollOwner(shell, 375); + }); + it("creates a condition-capable edge from the mobile simple graph without the canvas", async () => { mockWorkflowEditorViewport("mobile"); vi.mocked(fetchWorkflows).mockResolvedValue([def()]); @@ -588,6 +639,7 @@ describe("WorkflowNodeEditor", () => { fireEvent.click(await screen.findByRole("button", { name: "QA" })); await screen.findByText("Save"); const shell = await screen.findByTestId("wf-mobile-shell"); + assertSimpleEditorTabScrollOwner(shell, 375); expect(within(shell).queryByTestId("rf__wrapper")).not.toBeInTheDocument(); expect(screen.queryByTestId("mobile-wf-connect-start")).not.toBeInTheDocument(); expect(screen.queryByTestId("mobile-wf-connect-end")).not.toBeInTheDocument(); @@ -731,8 +783,9 @@ describe("WorkflowNodeEditor", () => { expect(await screen.findByTestId("wf-workflow-name")).toHaveTextContent("QA"); fireEvent.click(screen.getByTestId("wf-layout-toggle")); - expect(await screen.findByTestId("wf-mobile-shell")).toBeInTheDocument(); + const shell = await screen.findByTestId("wf-mobile-shell"); expect(screen.getByTestId("wf-mobile-tab-actions")).toBeInTheDocument(); + assertSimpleEditorTabScrollOwner(shell, 834); }); it("preselects the matching initial workflow id on desktop", async () => {