diff --git a/docs/solutions/ui-bugs/mobile-board-ios-horizontal-overscroll-containment.md b/docs/solutions/ui-bugs/mobile-board-ios-horizontal-overscroll-containment.md new file mode 100644 index 0000000000..ed24c8c4f3 --- /dev/null +++ b/docs/solutions/ui-bugs/mobile-board-ios-horizontal-overscroll-containment.md @@ -0,0 +1,64 @@ +--- +title: "Mobile board iOS horizontal overscroll containment" +date: 2026-06-13 +category: ui-bugs +module: packages/dashboard/app/styles.css +problem_type: ui_bug +component: frontend_css +symptoms: + - "On iOS Safari/PWA, dragging the kanban board past the first or last column rubber-bands the column strip off screen" + - "Horizontal edge overscroll can expose empty space and chain to the document even though the board's inner column scroll is intentional" +root_cause: css_scroll_containment_gap +resolution_type: code_fix +severity: medium +related_components: + - packages/dashboard/app/components/Lane.css + - packages/dashboard/app/__tests__/board-mobile-overscroll-containment.test.ts +tags: + - ios-safari + - mobile-board + - overscroll-behavior + - scroll-snap + - css-regression-test + - kanban +applies_when: + - "A horizontally scrollable board or lane strip uses `overflow-x: auto` with mobile momentum scrolling" + - "Edge dragging should keep native inner scrolling but must not chain or park content off screen" +--- + +# Mobile board iOS horizontal overscroll containment + +## Problem + +The mobile kanban board intentionally scrolls horizontally between columns using `overflow-x: auto`, `-webkit-overflow-scrolling: touch`, and `scroll-snap-type: x proximity`. On iOS Safari/PWA, that same momentum scroller can rubber-band past its first or last column if the scroller does not contain horizontal overscroll. The visible result is that the columns slide away from the viewport edge, exposing empty space and sometimes chaining the drag to the document. + +## Root cause + +The board had page-level mobile overscroll protection on `html, body`, but the board itself is the horizontal scroll container. The base `.board` and the mobile `@media (max-width: 768px) .board` rules declared the intended scroll and snap properties without `overscroll-behavior-x`, so iOS edge overscroll was not contained at the board boundary. Workflow and multi-lane board variants in `Lane.css` had the same independent horizontal scrollers. + +## Solution + +Add axis-specific containment to each horizontal board strip: + +```css +.board, +.board.board-workflow-columns, +.lane-columns { + overflow-x: auto; + overscroll-behavior-x: contain; + scroll-snap-type: x proximity; +} +``` + +Keep `contain` rather than `none`: the board can retain its native inner scroll feel while edge overscroll stops at the board/lane container instead of chaining outward. Do not replace this with `overflow: hidden`/`clip`, and do not switch snap back to `x mandatory`; both would regress intentional mobile column navigation. + +## Regression coverage + +Use a CSS-fixture test that loads the combined dashboard CSS and asserts: + +- the mobile `.board` rule still has `overflow-x: auto` and `scroll-snap-type: x proximity`; +- the mobile `.board` rule declares `overscroll-behavior-x: contain`; +- the base `.board`, `.board.board-workflow-columns`, and `.lane-columns` horizontal scrollers also declare containment; +- no checked board path uses `scroll-snap-type: x mandatory`. + +For FN-6378 this lives in `packages/dashboard/app/__tests__/board-mobile-overscroll-containment.test.ts`. diff --git a/packages/dashboard/app/__tests__/board-mobile-overscroll-containment.test.ts b/packages/dashboard/app/__tests__/board-mobile-overscroll-containment.test.ts new file mode 100644 index 0000000000..72dbb4cb5d --- /dev/null +++ b/packages/dashboard/app/__tests__/board-mobile-overscroll-containment.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { loadAllAppCss, loadAllAppCssBaseOnly } from "../test/cssFixture"; + +/** Extract all content inside @media (max-width: 768px) blocks. */ +function extractMobileMediaBlocks(content: string): string { + const blocks: string[] = []; + const regex = /@media[^{]*\(max-width: 768px\)[^{]*\{/g; + let match; + + while ((match = regex.exec(content)) !== null) { + const startIdx = match.index + match[0].length; + let braceCount = 1; + let endIdx = startIdx; + while (braceCount > 0 && endIdx < content.length) { + if (content[endIdx] === "{") braceCount++; + if (content[endIdx] === "}") braceCount--; + endIdx++; + } + if (braceCount === 0) { + blocks.push(content.slice(startIdx, endIdx - 1)); + } + } + return blocks.join("\n"); +} + +function extractRuleBlock(content: string, selector: string): string { + const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return content.match(new RegExp(`${escapedSelector}\\s*\\{[^}]*\\}`))?.[0] ?? ""; +} + +describe("board-mobile-overscroll-containment (FN-6378)", () => { + const cssContent = loadAllAppCss(); + const baseCss = loadAllAppCssBaseOnly(); + const mobileCss = extractMobileMediaBlocks(cssContent); + + it("mobile .board contains horizontal overscroll while preserving intentional scroll and proximity snap", () => { + const boardBlock = extractRuleBlock(mobileCss, ".board"); + + expect(boardBlock).toContain("overflow-x: auto"); + expect(boardBlock).toContain("overscroll-behavior-x: contain"); + expect(boardBlock).toContain("scroll-snap-type: x proximity"); + expect(boardBlock).not.toContain("scroll-snap-type: x mandatory"); + }); + + it("base .board contains horizontal overscroll for shared and tablet board scrollers", () => { + const boardBlock = extractRuleBlock(baseCss, ".board"); + + expect(boardBlock).toContain("overflow-x: auto"); + expect(boardBlock).toContain("overscroll-behavior-x: contain"); + expect(boardBlock).toContain("scroll-snap-type: x proximity"); + expect(boardBlock).not.toContain("scroll-snap-type: x mandatory"); + }); + + it("workflow columns and multi-lane column strips contain horizontal overscroll", () => { + const workflowColumnsBlock = extractRuleBlock(baseCss, ".board.board-workflow-columns"); + const laneColumnsBlock = extractRuleBlock(baseCss, ".lane-columns"); + + for (const block of [workflowColumnsBlock, laneColumnsBlock]) { + expect(block).toContain("overflow-x: auto"); + expect(block).toContain("overscroll-behavior-x: contain"); + expect(block).toContain("scroll-snap-type: x proximity"); + expect(block).not.toContain("scroll-snap-type: x mandatory"); + } + }); +}); diff --git a/packages/dashboard/app/components/Lane.css b/packages/dashboard/app/components/Lane.css index 6fe94990fc..a9514fc3e6 100644 --- a/packages/dashboard/app/components/Lane.css +++ b/packages/dashboard/app/components/Lane.css @@ -58,6 +58,7 @@ min-height: 0; overflow-x: auto; overflow-y: hidden; + overscroll-behavior-x: contain; scroll-snap-type: x proximity; } @@ -123,6 +124,7 @@ padding: 12px; overflow-x: auto; overflow-y: hidden; + overscroll-behavior-x: contain; scroll-snap-type: x proximity; scrollbar-color: var(--border) transparent; scrollbar-width: thin; diff --git a/packages/dashboard/app/styles.css b/packages/dashboard/app/styles.css index 1fd885343c..5e1f3bc1ea 100644 --- a/packages/dashboard/app/styles.css +++ b/packages/dashboard/app/styles.css @@ -996,6 +996,7 @@ body { padding: var(--board-padding); overflow-x: auto; overflow-y: hidden; + overscroll-behavior-x: contain; scroll-snap-type: x proximity; scroll-padding-inline: 50%; scrollbar-color: var(--border) transparent;