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) <noreply@anthropic.com>
This commit is contained in:
7
.changeset/settings-footer-touch-scroll.md
Normal file
7
.changeset/settings-footer-touch-scroll.md
Normal file
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user