FN-7621: fix mobile terminal shortcut bar horizontal scroll defeated by ancestor touch-action lock
Root-caused and fixed the third recurrence of the mobile terminal shortcut bar not scrolling horizontally: styles.css's mobile lockdown resets touch-action to pan-y across ancestors, and touch-action's used value is the intersection of the touched element's and every ancestor's value, so the leaf .terminal-shortcut-panel's pan-x was silently defeated even though it was already correct. - Opt the terminal overlay and modal ancestors (.modal-overlay.terminal-modal-overlay, .modal.terminal-modal--mobile, plain-media-query mobile modal, and the shortcut/status footer) into touch-action: pan-x pan-y so descendant leaf touch-action values can take effect - Add FNXC:Terminal comments documenting the ancestor-intersection root cause and recurrence history (FN-7550/FN-7560) - Add a documented solution note under docs/solutions/ui-bugs/ for the ancestor-intersection touch-action pattern - Add regression tests asserting the modal/overlay/footer ancestors carry the pan-x pan-y opt-in - Add a changeset for the fix Files changed: .../fn-7621-mobile-terminal-shortcut-scroll.md | 7 ++ ...on-ancestor-intersection-defeats-leaf-scroll.md | 57 +++++++++++ .../dashboard/app/components/TerminalModal.css | 38 ++++++++ .../components/__tests__/TerminalModal.test.tsx | 106 +++++++++++++++++++++ 4 files changed, 208 insertions(+) Fusion-Task-Id: FN-7621 Fusion-Task-Lineage: 771fd79e-e193-43b0-908b-0e8fe2fc2c70 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7621-mobile-terminal-shortcut-scroll.md
Normal file
7
.changeset/fn-7621-mobile-terminal-shortcut-scroll.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Fix the mobile terminal shortcut bar so it truly scrolls horizontally to reach every key.
|
||||
category: fix
|
||||
dev: FN-7550's leaf `min-width:0`/`overflow-x:auto`/`touch-action:pan-x` on `.terminal-shortcut-panel` were already correct, but styles.css's mobile `@media(max-width:768px)` lockdown resets `touch-action` to `pan-y` on `*` and re-locks it explicitly on `.modal-overlay:not(.confirm-dialog-overlay)`/`#root`/`html`/`body` — the terminal's own overlay/modal ancestors were never carved back into `pan-x`, so the panel's own correct touch-action was defeated by ancestor-chain intersection on real mobile devices. Added `touch-action: pan-x pan-y` to `.modal-overlay.terminal-modal-overlay`, `.modal.terminal-modal(.terminal-modal--mobile)` (both mobile paths), and `.terminal-status-bar` (FN-7560 footer, same gap). Locked in with a real-CSS `getComputedStyle` layout test (`loadAllAppCss()`) that resolves the panel + full ancestor chain, replacing reliance on a leaf-rule string match that stayed green through this recurrence.
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
title: "Mobile touch-action ancestor-chain intersection defeats a correct leaf scroll rule"
|
||||
date: 2026-07-06
|
||||
category: ui-bugs
|
||||
module: packages/dashboard/app/components/TerminalModal.css
|
||||
problem_type: ui_bug
|
||||
component: frontend_css
|
||||
symptoms:
|
||||
- "A flex row with overflow-x: auto, min-width: 0, flex-wrap: nowrap, and touch-action: pan-x still does not scroll horizontally on a real mobile touch device"
|
||||
- "Leaf-rule string-match tests (regex over the component's own CSS) stay green across multiple 'fixes' while the real symptom persists"
|
||||
root_cause: mobile_touch_action_ancestor_intersection
|
||||
resolution_type: code_fix
|
||||
severity: medium
|
||||
related_components:
|
||||
- packages/dashboard/app/styles.css
|
||||
- packages/dashboard/app/components/TerminalModal.tsx
|
||||
- packages/dashboard/app/components/__tests__/TerminalModal.test.tsx
|
||||
tags:
|
||||
- mobile-terminal
|
||||
- touch-action
|
||||
- css-cascade
|
||||
- scroll-containment
|
||||
- css-regression-test
|
||||
applies_when:
|
||||
- "A component-local horizontal (or vertical) scroll region sets its own touch-action but is nested inside a global mobile touch-action lockdown (e.g. `* { touch-action: pan-y }` to stop page-level pinch-zoom/rubber-band)"
|
||||
- "The leaf element's own touch-action is verified via computed style or rule-text match, but its ancestor chain up to html/body is not"
|
||||
---
|
||||
|
||||
# Mobile touch-action ancestor-chain intersection defeats a correct leaf scroll rule
|
||||
|
||||
## Problem (FN-7621, recurrence #3)
|
||||
|
||||
The terminal's mobile shortcut bar (`.terminal-shortcut-panel`) was fixed twice (FN-7550, FN-7560) for "does not scroll horizontally on mobile", and both fixes landed a leaf-rule string-match regression test that stayed green. The bug still reproduced on real mobile devices on the third report.
|
||||
|
||||
FN-7550/FN-7560 only ever verified the PANEL's own CSS text (`min-width: 0;`, `overflow-x: auto;`, `flex-wrap: nowrap;`, `touch-action: pan-x;`) — all of which were, in fact, correct. The actual defect lived in a completely different file: `styles.css`'s `@media (max-width: 768px)` mobile lockdown resets `touch-action: pan-y` on the universal selector (`* { touch-action: pan-y; }`, to stop pinch-zoom/rubber-band) and then explicitly restates `pan-y` on `#root`, `html`, `body`, and `.modal-overlay:not(.confirm-dialog-overlay)` (the cross-cutting "full-screen modal on mobile" rule, which matches the terminal's own overlay).
|
||||
|
||||
## Root cause
|
||||
|
||||
`touch-action`'s *used value* for a touch gesture is the **intersection** of the touched element's computed value and every ancestor's computed value along the DOM chain up to the document root — not just the touched element's own value. A leaf element can correctly compute `touch-action: pan-x` and still have horizontal panning fully blocked if ANY ancestor between it and `<html>` resolves to `pan-y` (or `none`), because the browser intersects the allowed axes at every level.
|
||||
|
||||
This codebase already has a working example of the fix pattern: `.board` is the *only* element deliberately "opted back into" `touch-action: pan-x pan-y` inside the SAME mobile lockdown block in `styles.css` (see the comment "the board is the only always-present horizontal scroller on mobile ... opt known horizontal scrollers back into pan-x below"). Any OTHER component that adds its own horizontal (or vertical) scroll region on mobile must be added to that same carve-out convention — giving the leaf element `touch-action: pan-x` in its own component CSS file is necessary but not sufficient.
|
||||
|
||||
## Why the leaf-rule tests missed it
|
||||
|
||||
- FN-7550/FN-7560's regression tests used `terminalModalCss.match(/\.terminal-shortcut-panel\s*\{([^}]*)\}/)` — a regex over ONE file's rule text. They could never see `styles.css`'s cross-cutting mobile lockdown, because they never looked at it.
|
||||
- Even a `getComputedStyle`-based test that only inspects the panel itself (not its ancestors) would still pass while the bug is present, because the panel's OWN resolved `touch-action` value is genuinely `pan-x` — the defeat happens at the ANCESTOR level via gesture-handling intersection, not via cascade override on the panel.
|
||||
|
||||
## Solution
|
||||
|
||||
1. Reproduce with a real-CSS layout test: load all app CSS (`loadAllAppCss()`) into a `<style>` element, render the component at a mobile viewport, and resolve `getComputedStyle` for the scrollable leaf AND every ancestor up to (at minimum) the portal root / overlay. Assert every ancestor's `touch-action` still allows the needed axis.
|
||||
2. Fix at the ancestor(s), not the leaf: add `touch-action: pan-x pan-y` (matching the existing `.board`/`pre`/`code`/`table` carve-out pattern) to the specific ancestor selectors that the mobile lockdown targets — in this case `.modal-overlay.terminal-modal-overlay` and `.modal.terminal-modal(.terminal-modal--mobile)` in `TerminalModal.css` (higher specificity than the universal `*` reset, so it applies regardless of stylesheet order).
|
||||
3. Do NOT loosen `html`/`body`/`#root`/the generic `.modal-overlay:not(.confirm-dialog-overlay)` rule — that page-level lock is intentional (prevents page-level horizontal rubber-band/pinch-zoom) and other modals rely on it. Only carve out the SPECIFIC component that needs the exception.
|
||||
4. Keep the leaf-rule string tests as a cheap floor (they still catch accidental leaf regressions), but they are NOT sufficient acceptance on their own — the computed-style ancestor-chain test is the real gate.
|
||||
|
||||
## Regression coverage
|
||||
|
||||
`packages/dashboard/app/components/__tests__/TerminalModal.test.tsx` → `describe("real-CSS mobile cascade (FN-7621 recurrence #3)")` resolves `getComputedStyle` for the panel plus its `.terminal-modal` and `.modal-overlay` ancestors, at both mobile fullscreen and the `--keyboard-overlap` narrowed-visual-viewport variant, and asserts `touchAction` allows `pan-x` end-to-end. Confirmed to fail on the pre-fix tree and pass post-fix.
|
||||
@@ -41,10 +41,15 @@ The terminal must NEVER dim or blur the page behind it, in ANY mode. The base .m
|
||||
FNXC:Terminal 2026-06-23-21:28:
|
||||
Theme-level modal backdrop rules can load after component CSS and reapply dim/blur, especially in glass themes. Use a higher-specificity terminal overlay selector and clear both standard and WebKit backdrop filters so docked and modal terminal surfaces never darken the app behind them.
|
||||
*/
|
||||
/*
|
||||
FNXC:Terminal 2026-07-06-19:45:
|
||||
FN-7621 recurrence #3 root cause: styles.css's mobile `@media (max-width: 768px)` block resets `* { touch-action: pan-y; }` and then explicitly re-locks `.modal-overlay:not(.confirm-dialog-overlay) { touch-action: pan-y; }` (the cross-cutting "full-screen modal on mobile" rule) — and this terminal overlay matches that selector. touch-action's used value for a touch gesture is the INTERSECTION of the touched element's value and every ancestor's value, not just the touched element's own computed value (confirmed by this codebase's own board-only carve-out: "the board is the only always-present horizontal scroller on mobile ... opt known horizontal scrollers back into pan-x below"). FN-7550/FN-7560 only ever edited `.terminal-shortcut-panel`'s OWN rule in this file — giving that leaf element `touch-action: pan-x` — but never carved the terminal's overlay/modal ancestors back out of the page-level pan-y lock, so the intersection stayed empty and the swipe never scrolled on a real mobile browser, even though the leaf-rule string test (and the width/overflow chain) were both already correct. Re-opt this overlay into horizontal panning so its descendants' own touch-action values can actually take effect.
|
||||
*/
|
||||
.modal-overlay.terminal-modal-overlay.terminal-modal-overlay {
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
|
||||
.modal-overlay.terminal-modal-overlay--docked.terminal-modal-overlay--docked,
|
||||
@@ -1398,6 +1403,17 @@ Android folded Chrome can keep a wide layout viewport while visualViewport is th
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
resize: none;
|
||||
/*
|
||||
FNXC:Terminal 2026-07-06-19:45:
|
||||
FN-7621: the mobile lockdown in styles.css resets `* { touch-action: pan-y; }`
|
||||
(and #root/html/body explicitly restate pan-y). This modal sits between that
|
||||
lock and .terminal-shortcut-panel/.terminal-status-bar, both of which need real
|
||||
horizontal panning — touch-action's used value intersects every ancestor along
|
||||
the touched chain, so this ancestor must also allow pan-x or the leaf panel's
|
||||
own touch-action: pan-x is silently defeated (the actual root cause of this
|
||||
recurrence; the panel's leaf rule was already correct).
|
||||
*/
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
|
||||
.modal.terminal-modal.terminal-modal--mobile .terminal-header {
|
||||
@@ -1473,6 +1489,18 @@ The Android keyboard-open recurrence can start with a touch-primary visualViewpo
|
||||
border: none;
|
||||
/* Disable user resize on mobile — fullscreen layout. */
|
||||
resize: none;
|
||||
/*
|
||||
FNXC:Terminal 2026-07-06-19:45:
|
||||
FN-7621: same touch-action carve-out as the .terminal-modal--mobile class
|
||||
rule above, for the plain-media-query mobile path (a real narrow layout
|
||||
viewport rather than the folded-Android visualViewport case). Without
|
||||
this, the ancestor-chain touch-action intersection from styles.css's
|
||||
mobile lockdown (`* { touch-action: pan-y }`, restated on #root/html/body
|
||||
and `.modal-overlay:not(.confirm-dialog-overlay)`) defeats
|
||||
.terminal-shortcut-panel's and .terminal-status-bar's own pan-x, even
|
||||
though those leaf rules are correct.
|
||||
*/
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
|
||||
/* Mobile swaps the desktop tab strip for a selector; wrapping is intentional on very narrow phones so worktree and session actions remain reachable. */
|
||||
@@ -1745,6 +1773,16 @@ The Android keyboard-open recurrence can start with a touch-primary visualViewpo
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
/*
|
||||
FNXC:Terminal 2026-07-06-19:45:
|
||||
FN-7621: this footer shares .terminal-shortcut-panel's FN-7550 min-width/
|
||||
overflow-x pattern but never had ITS OWN touch-action opt-in, so real
|
||||
mobile swipes on it were also defeated by the ancestor-chain touch-action
|
||||
intersection from styles.css's mobile lockdown (see the .modal.terminal-modal
|
||||
and .modal-overlay.terminal-modal-overlay carve-outs above/elsewhere in
|
||||
this file). Give it the same pan-x pan-y opt-in as .terminal-shortcut-panel.
|
||||
*/
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
|
||||
.terminal-shortcuts--header,
|
||||
|
||||
@@ -18,6 +18,7 @@ import * as useTerminalModule from "../../hooks/useTerminal";
|
||||
import * as useTerminalSessionsModule from "../../hooks/useTerminalSessions";
|
||||
import * as useWorkspacesModule from "../../hooks/useWorkspaces";
|
||||
import * as apiModule from "../../api";
|
||||
import { loadAllAppCss } from "../../test/cssFixture";
|
||||
|
||||
const terminalModalCss = readFileSync("app/components/TerminalModal.css", "utf8");
|
||||
|
||||
@@ -1496,6 +1497,111 @@ describe("TerminalModal", () => {
|
||||
expect(footerRule).toContain("min-width: 0;");
|
||||
});
|
||||
|
||||
describe("real-CSS mobile cascade (FN-7621 recurrence #3)", () => {
|
||||
// FN-7621: the FN-7550/FN-7560 tests above are leaf-rule string matches —
|
||||
// they proved the declarations exist, but never proved the panel actually
|
||||
// scrolls under the real mobile cascade (ancestor overrides, mobile
|
||||
// classes, media queries). This is exactly why the bug recurred a third
|
||||
// time while those tests stayed green: touch-action's used value for a
|
||||
// touch gesture is the INTERSECTION of the touched element's value and
|
||||
// every ancestor's value (confirmed by this codebase's own
|
||||
// "opt known horizontal scrollers back into pan-x" convention in
|
||||
// styles.css's mobile lockdown), so a correct leaf `touch-action: pan-x`
|
||||
// on `.terminal-shortcut-panel` was silently defeated by its ancestors
|
||||
// (`.terminal-modal`, `.modal-overlay`) staying locked to `pan-y` by that
|
||||
// same global mobile lockdown. Resolve real cascade winners via
|
||||
// `getComputedStyle` under `loadAllAppCss()` instead of matching rule text.
|
||||
let styleEl: HTMLStyleElement;
|
||||
|
||||
beforeAll(() => {
|
||||
styleEl = document.createElement("style");
|
||||
styleEl.textContent = loadAllAppCss();
|
||||
document.head.appendChild(styleEl);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
styleEl.remove();
|
||||
});
|
||||
|
||||
async function renderMobileShortcutPanel(): Promise<{ panel: HTMLElement; modal: HTMLElement; overlay: HTMLElement | null }> {
|
||||
Object.defineProperty(window, "innerWidth", { value: 390, configurable: true });
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
const toggle = await screen.findByTestId("terminal-shortcut-toggle");
|
||||
fireEvent.click(toggle);
|
||||
const panel = await screen.findByTestId("terminal-shortcut-panel");
|
||||
const modal = await screen.findByTestId("terminal-modal");
|
||||
const overlay = modal.closest(".modal-overlay") as HTMLElement | null;
|
||||
return { panel, modal, overlay };
|
||||
}
|
||||
|
||||
it("resolves the panel + full ancestor chain as width-bounded and pan-x-capable at mobile fullscreen", async () => {
|
||||
const { panel, modal, overlay } = await renderMobileShortcutPanel();
|
||||
expect(modal).toHaveClass("terminal-modal--mobile");
|
||||
|
||||
const panelStyle = getComputedStyle(panel);
|
||||
expect(panelStyle.minWidth).toBe("0px");
|
||||
expect(panelStyle.overflowX).toBe("auto");
|
||||
expect(panelStyle.flexWrap).toBe("nowrap");
|
||||
expect(panelStyle.touchAction).toContain("pan-x");
|
||||
|
||||
// The direct parent (.terminal-modal) must itself be width-bounded
|
||||
// (overflow: hidden + a definite width/max-width) AND must not
|
||||
// re-lock horizontal panning for its descendants — this is the
|
||||
// ancestor that FN-7550/FN-7560 never touched.
|
||||
const modalStyle = getComputedStyle(modal);
|
||||
expect(modalStyle.overflow).toBe("hidden");
|
||||
expect(modalStyle.maxWidth).not.toBe("");
|
||||
expect(modalStyle.touchAction).toContain("pan-x");
|
||||
|
||||
// The portaled overlay ancestor is the OTHER surface the mobile
|
||||
// lockdown explicitly re-locks to pan-y (`.modal-overlay:not(.confirm-dialog-overlay)`
|
||||
// in styles.css) — it must be carved back out too.
|
||||
expect(overlay).toBeTruthy();
|
||||
const overlayStyle = getComputedStyle(overlay as HTMLElement);
|
||||
expect(overlayStyle.touchAction).toContain("pan-x");
|
||||
});
|
||||
|
||||
it("keeps the ancestor chain pan-x-capable in the --keyboard-overlap narrowed-visual-viewport variant", async () => {
|
||||
const { panel, modal, overlay } = await renderMobileShortcutPanel();
|
||||
|
||||
// Simulate the keyboard-open narrowed-visual-viewport variant
|
||||
// (--vv-width narrower than the layout viewport) directly on the DOM
|
||||
// node — this exercises the `.terminal-modal--mobile[style*="--keyboard-overlap"]`
|
||||
// selector's cascade without needing the full focus/resize keyboard
|
||||
// simulation machinery used elsewhere in this file.
|
||||
act(() => {
|
||||
modal.style.setProperty("--keyboard-overlap", "300px");
|
||||
modal.style.setProperty("--vv-width", "320px");
|
||||
modal.style.setProperty("--vv-height", "380px");
|
||||
});
|
||||
|
||||
const panelStyle = getComputedStyle(panel);
|
||||
expect(panelStyle.minWidth).toBe("0px");
|
||||
expect(panelStyle.overflowX).toBe("auto");
|
||||
expect(panelStyle.touchAction).toContain("pan-x");
|
||||
|
||||
const modalStyle = getComputedStyle(modal);
|
||||
expect(modalStyle.maxWidth).toBe("var(--vv-width, 100vw)");
|
||||
expect(modalStyle.touchAction).toContain("pan-x");
|
||||
|
||||
expect(overlay).toBeTruthy();
|
||||
expect(getComputedStyle(overlay as HTMLElement).touchAction).toContain("pan-x");
|
||||
});
|
||||
|
||||
it("does not regress desktop docked/floating/pinned-below touch-action or width contracts", async () => {
|
||||
Object.defineProperty(window, "innerWidth", { value: 1280, configurable: true });
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
const modal = await screen.findByTestId("terminal-modal");
|
||||
expect(modal).not.toHaveClass("terminal-modal--mobile");
|
||||
// Desktop never had a touch-action restriction on the modal/overlay —
|
||||
// this fix only carves out the mobile-locked ancestors, so desktop's
|
||||
// computed touch-action must stay whatever it already was (unset/auto),
|
||||
// never narrowed to something that would block desktop pointer input.
|
||||
const modalStyle = getComputedStyle(modal);
|
||||
expect(modalStyle.touchAction === "" || modalStyle.touchAction === "auto").toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("is hidden by default and toggles from header action", async () => {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user