From 20c5e1ff4ef7c257035742a258e5ac08aad98a9a Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 25 Jul 2026 20:18:45 -0700 Subject: [PATCH] fix(dashboard): let the mobile settings footer scroll sideways by touch The footer rail already had overflow-x: auto, so it scrolled with a mouse, but the global mobile `* { touch-action: pan-y }` lock intersected horizontal pans away from every element. Opt the rail and its inner touch targets (the buttons receive the touchstart; touch-action is not inherited) back into pan-x, contain overscroll so a fling does not chain out to the document, and free the button groups from the mobile max-width: 100% reset that squeezed them into overlap instead of widening the scrollable content. Also align the footer's media query with MOBILE_MEDIA_QUERY (max-width 768px OR max-height 480px), which SettingsModal.tsx uses to pick the mobile footer markup: landscape phones were rendering mobile markup under desktop CSS. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/settings-footer-touch-scroll.md | 7 ++ .../app/components/SettingsModal.css | 47 +++++++++++- .../__tests__/settings-mobile.test.tsx | 74 +++++++++++++++++++ 3 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 .changeset/settings-footer-touch-scroll.md diff --git a/.changeset/settings-footer-touch-scroll.md b/.changeset/settings-footer-touch-scroll.md new file mode 100644 index 0000000000..afcf2b292b --- /dev/null +++ b/.changeset/settings-footer-touch-scroll.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Let the mobile Settings footer scroll sideways by touch when its buttons overflow the screen. +category: fix +dev: The footer rail already had `overflow-x: auto`, but the global mobile `* { touch-action: pan-y }` lock swallowed horizontal drags; the rail and its inner touch targets now opt back into `pan-x`, groups escape the mobile `max-width: 100%` reset, and the footer block tracks the full mobile breakpoint (`max-width: 768px, max-height: 480px`) so landscape phones get the same rail. diff --git a/packages/dashboard/app/components/SettingsModal.css b/packages/dashboard/app/components/SettingsModal.css index 8b37617bca..c312f7efa1 100644 --- a/packages/dashboard/app/components/SettingsModal.css +++ b/packages/dashboard/app/components/SettingsModal.css @@ -339,7 +339,16 @@ Fix the invariant for BOTH presentations (standalone modal + embedded SettingsVi min-width: 0; } -@media (max-width: 768px) { +/* +FNXC:SettingsLayout 2026-07-25-21:05: +This footer block tracks the app-wide mobile breakpoint (`MOBILE_MEDIA_QUERY` in useViewportMode.ts = +`(max-width: 768px), (max-height: 480px)`), not width alone. SettingsModal.tsx switches to the mobile footer +MARKUP off that hook (short version/Reset labels plus the separate `.settings-modal-footer-update-row`), +so a landscape phone (e.g. 812x375 — width > 768, height <= 480) used to render mobile markup under desktop +footer CSS: the update row lost its padding/divider and the rail wrapped instead of scrolling. Keep the two in +sync so both orientations get the same footer. +*/ +@media (max-width: 768px), (max-height: 480px) { /* FNXC:Settings 2026-07-09-00:00: FN-7752 requires the mobile Settings footer to remain a single horizontal row in both standalone and embedded presentations. Keep Version/Help, Export/Import/Reset, and Cancel/Save on one nowrap rail that can scroll horizontally instead of stacking into multiple rows. @@ -349,6 +358,17 @@ Fix the invariant for BOTH presentations (standalone modal + embedded SettingsVi FNXC:Settings 2026-07-22-12:00: Mobile Settings bottom buttons must read as one centered control cluster with even token spacing, not the desktop edge-split layout. Override the base `.modal-actions` flex-end + `.modal-actions-left { margin-right: auto }` push so Help/Version, Import/Export/Reset, and Close sit together in the middle of the rail. Use `safe center` so an overflowing update-result/control row still starts at the scroll origin instead of clipping the leading buttons. Gap is `var(--space-sm)` between groups and `var(--space-xs)` inside groups for consistent breathing room on narrow screens. Applies to both standalone modal and embedded SettingsView (shared `.settings-modal .modal-actions`). + + FNXC:SettingsLayout 2026-07-25-21:05: + The rail must be TOUCH-scrollable, and only when its content is actually wider than the viewport. + `overflow-x: auto` alone gave that on desktop pointers but NOT on a phone: FN-6365's global mobile lock + (`* { touch-action: pan-y }` in styles.css) intersects horizontal pans away from every element, so a finger + drag across Help/version/Import/Export/Reset/Close did nothing. `touch-action` is not inherited, so the rail + AND every touch target inside it (the buttons themselves receive the touchstart) must opt back into `pan-x` + — the same container+targets pattern as the board and AgentDetailView tabs. `overscroll-behavior-x: contain` + keeps a fling at either end from chaining out to the document/board swipe handlers. + "Only if wider than viewport" is `overflow-x: auto` doing its job: when the cluster fits, there is no scroll + range, the rail stays centered, and vertical page panning is unaffected. */ .settings-modal .modal-actions { flex-wrap: nowrap; @@ -359,13 +379,36 @@ Fix the invariant for BOTH presentations (standalone modal + embedded SettingsVi gap: var(--space-sm); overflow-x: auto; overflow-y: hidden; + overscroll-behavior-x: contain; + touch-action: pan-x pan-y; + -webkit-overflow-scrolling: touch; + min-width: 0; + max-width: 100%; } + /* + FNXC:SettingsLayout 2026-07-25-21:05: + Non-inherited `touch-action` again: the buttons/labels are the actual touch targets, so without this the + global `pan-y` lock still swallows a drag that starts on a button (which is most of the rail's surface). + Scoped to this footer so nothing else loses the FN-6365 vertical-only default. + */ + .settings-modal .modal-actions * { + touch-action: pan-x pan-y; + } + + /* + FNXC:SettingsLayout 2026-07-25-21:05: + `max-width: none` undoes the mobile universal max-width-100% reset in styles.css for the footer's button + groups only. A group clamped to the rail's own width cannot contain its non-shrinking buttons — they spill + out of their box (overlapping the neighbouring group) instead of making the rail's scrollable content wider, + which is the width the scroll range is computed from. + */ .settings-modal .modal-actions-left { align-items: center; flex-shrink: 0; gap: var(--space-xs); margin-right: 0; + max-width: none; } .settings-modal .modal-actions-right { @@ -373,6 +416,7 @@ Fix the invariant for BOTH presentations (standalone modal + embedded SettingsVi flex-shrink: 0; gap: var(--space-xs); margin-left: 0; + max-width: none; } .settings-modal .settings-modal-footer-version { @@ -381,6 +425,7 @@ Fix the invariant for BOTH presentations (standalone modal + embedded SettingsVi flex: 0 0 auto; margin-right: 0; min-width: max-content; + max-width: none; } /* diff --git a/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx b/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx index 1ed7a9b85a..15633b7385 100644 --- a/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx @@ -229,6 +229,37 @@ function expectNoMobileRule(css: string, selector: string, declaration: string): expect(offendingBlock).toBeUndefined(); } +/** + * Returns the full text of the first `@media` block whose body satisfies `predicate`. + * + * Note: this brace-matches from the block's OPENING brace outward (unlike `getMobileMediaBlocks` + * above, which starts its depth counter before the `@media` prelude and therefore terminates at the + * first character), so the returned string is the real block and assertions against it can fail. + */ +function findMediaBlock(css: string, predicate: (block: string) => boolean): string | undefined { + const mediaPattern = /@media[^{]*\{/g; + let match: RegExpExecArray | null; + + while ((match = mediaPattern.exec(css)) !== null) { + const open = match.index + match[0].length - 1; + let depth = 0; + let end = open; + for (; end < css.length; end += 1) { + if (css[end] === "{") depth += 1; + if (css[end] === "}") depth -= 1; + if (depth === 0) { + end += 1; + break; + } + } + const block = css.slice(match.index, end); + if (predicate(block)) return block; + mediaPattern.lastIndex = end; + } + + return undefined; +} + function expectBaseRule(css: string, selector: string, declaration: string): void { const pattern = new RegExp( `${escapeRegExp(selector)}\\s*\\{[^}]*${escapeRegExp(declaration)}`, @@ -894,6 +925,49 @@ describe("SettingsModal mobile adaptations", () => { expectBaseRule(css, ".settings-header-actions > .settings-header-discord-btn", "height: var(--settings-header-action-height);"); }); + it("makes the mobile settings footer rail touch-scrollable at both mobile orientations", () => { + const css = loadAllAppCss(); + + /* + Surface enumeration for "the footer scrolls horizontally on touch, but only when it overflows": + 1. the media query gating the footer must be the app-wide mobile breakpoint (portrait width AND + landscape height), because SettingsModal.tsx picks the mobile footer markup off that same query; + 2. the rail itself must opt back into horizontal panning past the global `* { touch-action: pan-y }`; + 3. every touch target INSIDE the rail must opt in too (touch-action is not inherited, and the buttons + cover most of the rail's surface); + 4. scrolling stays conditional — `overflow-x: auto`, never `scroll`, so a fitting cluster has no + scroll range and keeps its centered layout; + 5. the non-shrinking button groups must escape the mobile `* { max-width: 100% }` reset, otherwise the + rail has nothing wider than itself to scroll to. + Both presentations (standalone modal + embedded SettingsView) share these `.settings-modal` selectors. + */ + const footerBlock = findMediaBlock(css, (block) => /\.settings-modal \.modal-actions\s*\{[^}]*overflow-x:/.test(block)); + expect(footerBlock).toBeTruthy(); + + // 1. Landscape phones (width > 768px, height <= 480px) resolve to viewportMode "mobile" in TSX. + const footerQuery = footerBlock!.slice(0, footerBlock!.indexOf("{")); + expect(footerQuery).toContain("max-width: 768px"); + expect(footerQuery).toContain("max-height: 480px"); + + const railRule = footerBlock!.match(/\.settings-modal \.modal-actions\s*\{([^}]*)\}/)?.[1] ?? ""; + // 2 + 4. + expect(railRule).toContain("touch-action: pan-x pan-y;"); + expect(railRule).toContain("overscroll-behavior-x: contain;"); + expect(railRule).toContain("-webkit-overflow-scrolling: touch;"); + expect(railRule).toContain("overflow-x: auto;"); + expect(railRule).not.toContain("overflow-x: scroll;"); + + // 3. + const targetsRule = footerBlock!.match(/\.settings-modal \.modal-actions \*\s*\{([^}]*)\}/)?.[1] ?? ""; + expect(targetsRule).toContain("touch-action: pan-x pan-y;"); + + // 5. + for (const selector of [".settings-modal .modal-actions-left", ".settings-modal .modal-actions-right", ".settings-modal .settings-modal-footer-version"]) { + const groupRule = footerBlock!.match(new RegExp(`${escapeRegExp(selector)}\\s*\\{([^}]*)\\}`))?.[1] ?? ""; + expect(groupRule).toContain("max-width: none;"); + } + }); + it("FN-4354: settings header actions and modal-close have no mobile touch-target inflation", () => { const css = loadAllAppCss();