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
This commit is contained in:
@@ -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.
|
||||
@@ -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;");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user