From 88e25dc9f476c3fbd83ca6ef42934acc6a86d105 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 13 Jun 2026 11:22:07 -0700 Subject: [PATCH] FN-6365: prevent mobile document horizontal panning Contain the mobile dashboard document viewport while preserving intended inner horizontal scrollers. - Lock mobile html/body/#root and fullscreen overlay chrome to the viewport inline axis. - Default mobile touch handling to vertical-only panning and opt board/code/table scrollers back into horizontal gestures. - Add CSS fixture regression coverage plus a solution note for mobile horizontal pan containment. Files changed: docs/solutions/ui-bugs/mobile-horizontal-pan-document-viewport-containment.md | 62 +++++++++++ packages/dashboard/app/__tests__/mobile-horizontal-pan-containment.test.ts | 116 +++++++++++++++++++++ packages/dashboard/app/styles.css | 47 +++++++-- 3 files changed, 219 insertions(+), 6 deletions(-) Fusion-Task-Id: FN-6365 Fusion-Task-Lineage: 4673da28-1438-4ea5-b32c-25bc52285273 --- ...ontal-pan-document-viewport-containment.md | 62 ++++++++++ .../mobile-horizontal-pan-containment.test.ts | 116 ++++++++++++++++++ packages/dashboard/app/styles.css | 47 ++++++- 3 files changed, 219 insertions(+), 6 deletions(-) create mode 100644 docs/solutions/ui-bugs/mobile-horizontal-pan-document-viewport-containment.md create mode 100644 packages/dashboard/app/__tests__/mobile-horizontal-pan-containment.test.ts diff --git a/docs/solutions/ui-bugs/mobile-horizontal-pan-document-viewport-containment.md b/docs/solutions/ui-bugs/mobile-horizontal-pan-document-viewport-containment.md new file mode 100644 index 0000000000..b8bd147ac1 --- /dev/null +++ b/docs/solutions/ui-bugs/mobile-horizontal-pan-document-viewport-containment.md @@ -0,0 +1,62 @@ +--- +title: "Mobile document horizontal pan containment" +date: 2026-06-13 +category: ui-bugs +module: packages/dashboard/app/styles.css +problem_type: ui_bug +component: frontend_css +symptoms: + - "On mobile, the entire dashboard can be horizontally panned into a shifted state" + - "Header, board, and footer slide left together while a dark empty void appears on the right" + - "The inner kanban board should scroll horizontally, but the document/page itself must not" +root_cause: mobile_viewport_containment +resolution_type: css_fix +severity: high +related_components: + - packages/dashboard/app/__tests__/mobile-horizontal-pan-containment.test.ts + - packages/dashboard/app/__tests__/mobile-scroll-snap.test.ts + - packages/dashboard/app/__tests__/board-tablet-overflow.test.ts +tags: + - mobile + - viewport + - overflow + - touch-action + - visual-viewport + - kanban-board +--- + +# Mobile document horizontal pan containment + +## Problem + +The mobile dashboard can enter a broken off-axis state where the whole page chrome shifts left and exposes an empty dark strip on the right. The screenshot for FN-6365 showed the header, board, and footer all shifted together, which means the document/visual viewport was panned horizontally — not just the intended `.board` column strip. + +## Root cause + +The mobile global CSS locked `overflow: hidden` on `html`, `body`, and `#root`, but every element was also assigned `touch-action: pan-x pan-y`. That allowed horizontal gestures that began on root chrome, fixed bars, modal chrome, or other non-board surfaces to be interpreted as page-level horizontal panning. The board was the intended horizontal scroller, but the document root did not explicitly enforce vertical-only touch handling, `overflow-x: hidden`, and `overscroll-behavior-x: none` as separate invariants. + +Fullscreen mobile overlays were also only constrained by `width/max-width: 100%`; adding logical inline-size constraints keeps modal/overlay chrome from widening the document when the layout viewport and visual viewport diverge. + +## Fix + +In the mobile `@media (max-width: 768px)` global block: + +- Lock `html`, `body`, and `#root` to the viewport inline axis with `width/max-width: 100%`, `overflow-x: hidden`, and `overscroll-behavior-x: none`. +- Make document-root/default touch handling vertical-only with `touch-action: pan-y`. +- Opt the known legitimate horizontal scrollers back into `touch-action: pan-x pan-y`: `.board`, `pre`, `code`, `.code-block`, and `table`. +- Keep `.board` horizontally scrollable with `overflow-x: auto`, `-webkit-overflow-scrolling: touch`, and `scroll-snap-type: x proximity`. +- Constrain mobile fullscreen overlay/modal chrome with `inline-size: 100%`, `max-inline-size: 100%`, and `min-width: 0` where appropriate. + +## Regression coverage + +`packages/dashboard/app/__tests__/mobile-horizontal-pan-containment.test.ts` asserts the containment contract directly from CSS fixtures: + +- Mobile root has `overflow-x: hidden`, `overscroll-behavior-x: none`, and `touch-action: pan-y`. +- The mobile `.board` still has `overflow-x: auto` and `scroll-snap-type: x proximity`. +- Code/table opt-in horizontal scrollers keep `touch-action: pan-x pan-y`. +- Fullscreen overlay/modal chrome is constrained to the viewport inline size. +- The tablet `.board` overflow rule remains unchanged. + +## Pitfall + +Do not fix this class by blanket-clipping all descendants or removing `.board` horizontal scrolling. The board, code blocks, and tables are valid inner horizontal scrollers; the invariant is that the document/visual viewport itself must stay at horizontal offset zero. diff --git a/packages/dashboard/app/__tests__/mobile-horizontal-pan-containment.test.ts b/packages/dashboard/app/__tests__/mobile-horizontal-pan-containment.test.ts new file mode 100644 index 0000000000..2cb72cef28 --- /dev/null +++ b/packages/dashboard/app/__tests__/mobile-horizontal-pan-containment.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import { loadAllAppCss } from "../test/cssFixture"; + +function extractMediaBlocks(content: string, pattern: RegExp): string { + const blocks: string[] = []; + + for (const match of content.matchAll(pattern)) { + const start = match.index! + match[0].length; + let index = start; + let depth = 1; + while (index < content.length && depth > 0) { + if (content[index] === "{") depth++; + if (content[index] === "}") depth--; + index++; + } + expect(depth).toBe(0); + blocks.push(content.slice(start, index - 1)); + } + + expect(blocks.length).toBeGreaterThan(0); + return blocks.join("\n"); +} + +function ruleBlock(css: string, selector: string): string { + const blocks = ruleBlocks(css, selector); + expect(blocks.length, `missing CSS rule for ${selector}`).toBeGreaterThan(0); + return blocks[0]; +} + +function ruleBlocks(css: string, selector: string): string[] { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return [...css.matchAll(new RegExp(`${escaped}\\s*\\{[^}]*\\}`, "gs"))].map((match) => match[0]); +} + +function declarationValue(rule: string, property: string): string | null { + const escaped = property.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = rule.match(new RegExp(`${escaped}\\s*:\\s*([^;]+);`)); + return match?.[1]?.trim() ?? null; +} + +describe("mobile horizontal pan containment (FN-6365)", () => { + const css = loadAllAppCss(); + const mobileCss = extractMediaBlocks(css, /@media\s*\([^)]*max-width:\s*768px[^)]*\)[^{]*\{/g); + const tabletCss = extractMediaBlocks(css, /@media\s*\(\s*min-width:\s*769px\s*\)\s*and\s*\(\s*max-width:\s*1024px\s*\)\s*\{/g); + + it("locks the document root against horizontal page panning on mobile", () => { + const rootBlock = ruleBlock(mobileCss, "html,\n body"); + const appRootBlock = ruleBlock(mobileCss, "#root"); + const starBlocks = ruleBlocks(mobileCss, "*"); + const defaultTouchBlock = starBlocks.find((block) => block.includes("touch-action: pan-y;")) ?? ""; + const widthContainmentBlock = starBlocks.find((block) => block.includes("max-inline-size: 100%;")) ?? ""; + + expect(rootBlock).toContain("overflow-x: hidden;"); + expect(rootBlock).toContain("overscroll-behavior-x: none;"); + expect(rootBlock).toContain("touch-action: pan-y;"); + expect(rootBlock).toContain("width: 100%;"); + expect(rootBlock).toContain("max-width: 100%;"); + + expect(appRootBlock).toContain("overflow-x: hidden;"); + expect(appRootBlock).toContain("overscroll-behavior-x: none;"); + expect(appRootBlock).toContain("touch-action: pan-y;"); + expect(appRootBlock).toContain("min-width: 0;"); + + expect(declarationValue(defaultTouchBlock, "touch-action")).toBe("pan-y"); + expect(widthContainmentBlock).toContain("max-width: 100%;"); + expect(widthContainmentBlock).toContain("max-inline-size: 100%;"); + }); + + it("preserves intentional horizontal scrolling for the mobile board and other opt-in scrollers", () => { + const boardBlock = ruleBlock(mobileCss, ".board"); + const codeBlock = ruleBlock(mobileCss, "pre,\n code,\n .code-block"); + const tableBlock = ruleBlock(mobileCss, "table"); + + expect(boardBlock).toContain("overflow-x: auto;"); + expect(boardBlock).toContain("scroll-snap-type: x proximity;"); + expect(boardBlock).toContain("-webkit-overflow-scrolling: touch;"); + expect(boardBlock).toContain("overscroll-behavior-x: contain;"); + expect(boardBlock).toContain("touch-action: pan-x pan-y;"); + expect(boardBlock).toContain("max-inline-size: 100%;"); + + expect(codeBlock).toContain("overflow-x: auto;"); + expect(codeBlock).toContain("touch-action: pan-x pan-y;"); + expect(tableBlock).toContain("overflow-x: auto;"); + expect(tableBlock).toContain("touch-action: pan-x pan-y;"); + }); + + it("constrains mobile fullscreen overlays to the viewport inline size", () => { + const overlayBlock = ruleBlock( + mobileCss, + ".modal-overlay:not(.confirm-dialog-overlay),\n .agent-detail-overlay,\n .agent-dialog-overlay,\n .workflow-output-modal-overlay", + ); + const modalBlock = ruleBlock( + mobileCss, + ".modal:not(.confirm-dialog),\n .modal-lg,\n .modal-md,\n .gm-modal", + ); + + expect(overlayBlock).toContain("inline-size: 100%;"); + expect(overlayBlock).toContain("max-inline-size: 100%;"); + expect(overlayBlock).toContain("overflow-x: hidden;"); + expect(overlayBlock).toContain("overscroll-behavior-x: none;"); + expect(overlayBlock).toContain("touch-action: pan-y;"); + + expect(modalBlock).toContain("inline-size: 100%;"); + expect(modalBlock).toContain("max-inline-size: 100%;"); + expect(modalBlock).toContain("min-width: 0;"); + expect(modalBlock).toContain("height: 100dvh;"); + }); + + it("leaves the tablet board horizontal overflow rule intact", () => { + const boardBlock = ruleBlock(tabletCss, ".board"); + + expect(boardBlock).toContain("grid-template-columns: repeat(6, minmax(260px, 1fr));"); + expect(boardBlock).toContain("overflow-x: auto;"); + expect(boardBlock).not.toContain("touch-action: pan-y;"); + }); +}); diff --git a/packages/dashboard/app/styles.css b/packages/dashboard/app/styles.css index 0f5f9beca3..ca5015abf7 100644 --- a/packages/dashboard/app/styles.css +++ b/packages/dashboard/app/styles.css @@ -3256,32 +3256,51 @@ input[type="range"]:focus-visible { font-size: 16px; } - /* Prevent ancestor elements from producing a second horizontal scrollbar */ + /* Lock the document to the visual viewport's inline axis. The board is the + only always-present horizontal scroller on mobile; root/header/footer + gestures must stay vertical-only so iOS/Android cannot park the whole + page off-axis and expose the offscreen-right void. */ html, body { + width: 100%; + max-width: 100%; overflow: hidden; + overflow-x: hidden; + overflow-y: hidden; /* Stop Chrome's overscroll/rubber-band on mobile — without this the user can pull the page up to expose empty space above the dashboard. */ overscroll-behavior: none; + overscroll-behavior-x: none; + overscroll-behavior-y: none; + touch-action: pan-y; } /* Disable pinch-zoom globally on mobile. Android Chrome ignores `user-scalable=no` for a11y, and the kanban board's horizontally- scrollable layout interacts badly with zoom-out (exposes the offscreen-right area). `touch-action` is not inherited — it applies - to the target element only — so we have to set `pan-x pan-y` - (keep scroll panning, block pinch-zoom) on every element. */ + to the target element only — so default every element to vertical page + panning, then opt known horizontal scrollers back into pan-x below. */ * { - touch-action: pan-x pan-y; + touch-action: pan-y; } #root { + width: 100%; + max-width: 100%; + min-width: 0; overflow: hidden; + overflow-x: hidden; + overflow-y: hidden; + overscroll-behavior-x: none; + touch-action: pan-y; } - /* Prevent horizontal overflow from wide content */ + /* Prevent horizontal overflow from wide content without sizing descendants + to the layout viewport when the visual viewport is narrower/drifted. */ * { - max-width: 100vw; + max-width: 100%; + max-inline-size: 100%; } pre, @@ -3289,6 +3308,8 @@ input[type="range"]:focus-visible { .code-block { overflow-x: auto; max-width: 100%; + max-inline-size: 100%; + touch-action: pan-x pan-y; word-break: break-all; word-break: break-word; } @@ -3307,6 +3328,8 @@ input[type="range"]:focus-visible { overflow-x: auto; -webkit-overflow-scrolling: touch; max-width: 100%; + max-inline-size: 100%; + touch-action: pan-x pan-y; } /* Global touch target enforcement on mobile */ @@ -3341,6 +3364,8 @@ input[type="range"]:focus-visible { display: flex; overflow-x: auto; overflow-y: hidden; + overscroll-behavior-x: contain; + touch-action: pan-x pan-y; -webkit-overflow-scrolling: touch; scroll-snap-type: x proximity; overflow-anchor: none; @@ -3351,6 +3376,8 @@ input[type="range"]:focus-visible { padding-bottom: var(--space-md); gap: var(--space-md); width: 100%; + max-width: 100%; + max-inline-size: 100%; } .board::-webkit-scrollbar { @@ -3378,6 +3405,11 @@ input[type="range"]:focus-visible { .workflow-output-modal-overlay { padding-top: 0; align-items: stretch; + inline-size: 100%; + max-inline-size: 100%; + overflow-x: hidden; + overscroll-behavior-x: none; + touch-action: pan-y; } .modal:not(.confirm-dialog), @@ -3386,6 +3418,9 @@ input[type="range"]:focus-visible { .gm-modal { width: 100%; max-width: 100%; + inline-size: 100%; + max-inline-size: 100%; + min-width: 0; height: 100vh; height: 100dvh; max-height: 100vh;