From 3d91bd5c68d33c491557b800d95fbaf0bf56e667 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Sat, 28 Mar 2026 14:37:57 -0700 Subject: [PATCH] feat: touch scroll for terminal and sidebar on mobile Terminal: add touchstart/touchmove/touchend IIFE on #terminal-container. Vertical swipe delta translated to _term.scrollLines(n). touchmove is non-passive (required for e.preventDefault to block page scroll while swiping inside terminal). mouse wheel scroll unaffected (separate wheel event path in xterm.js). Sidebar: add touch-action:pan-y + overscroll-behavior:contain to .sidebar-list. Tells browser explicitly that vertical panning is allowed and prevents scroll chaining when reaching list ends. --- muxplex/frontend/style.css | 2 ++ muxplex/frontend/terminal.js | 39 ++++++++++++++++++++++++ muxplex/frontend/tests/test_terminal.mjs | 29 ++++++++++++++++++ muxplex/tests/test_frontend_css.py | 17 +++++++++++ 4 files changed, 87 insertions(+) diff --git a/muxplex/frontend/style.css b/muxplex/frontend/style.css index d6e4a2a..a56861b 100644 --- a/muxplex/frontend/style.css +++ b/muxplex/frontend/style.css @@ -427,6 +427,8 @@ body { display: flex; flex-direction: column; gap: 6px; + touch-action: pan-y; /* tell browser vertical panning is allowed on mobile */ + overscroll-behavior: contain; /* prevent scroll chaining to non-scrollable parent */ } .sidebar-collapse-btn { diff --git a/muxplex/frontend/terminal.js b/muxplex/frontend/terminal.js index 69506cc..33809f3 100644 --- a/muxplex/frontend/terminal.js +++ b/muxplex/frontend/terminal.js @@ -246,3 +246,42 @@ function closeTerminal() { // ─── Expose to app.js ───────────────────────────────────────────────────────── window._openTerminal = openTerminal; window._closeTerminal = closeTerminal; + +// --------------------------------------------------------------------------- +// Touch scroll — translates vertical swipe into xterm.js scrollback +// --------------------------------------------------------------------------- +// Runs once after DOM is ready. Mouse wheel scroll is unaffected (separate +// `wheel` event path handled natively by xterm.js). +;(function initTerminalTouchScroll() { + var container = document.getElementById('terminal-container'); + if (!container || typeof container.addEventListener !== 'function') return; + + var _touchLastY = 0; + + container.addEventListener('touchstart', function (e) { + _touchLastY = e.touches[0].clientY; + }, { passive: true }); + + container.addEventListener('touchmove', function (e) { + if (!_term) return; + e.preventDefault(); // prevent page scroll while swiping inside terminal + + var y = e.touches[0].clientY; + var deltaY = _touchLastY - y; // positive = swipe up = scroll toward newer content + _touchLastY = y; + + // Convert pixels to lines. xterm.js fontSize is set per device (12 mobile, 14 desktop). + // lineHeight ratio is 1.2 — gives ~14-17px per line. + var pxPerLine = (_term.options.fontSize || 14) * 1.2; + var lines = deltaY / pxPerLine; + + // Only fire if movement is meaningful (avoids micro-jitter) + if (Math.abs(lines) >= 0.3) { + _term.scrollLines(Math.round(lines)); + } + }, { passive: false }); // passive: false required to allow e.preventDefault() + + container.addEventListener('touchend', function () { + _touchLastY = 0; + }, { passive: true }); +})(); diff --git a/muxplex/frontend/tests/test_terminal.mjs b/muxplex/frontend/tests/test_terminal.mjs index 86d9f6f..3dfb846 100644 --- a/muxplex/frontend/tests/test_terminal.mjs +++ b/muxplex/frontend/tests/test_terminal.mjs @@ -5,6 +5,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; +import fs from 'node:fs'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -717,3 +718,31 @@ test('terminal is auto-focused when WebSocket opens', () => { assert.strictEqual(t.focusCallCount, 1, '_term.focus() should be called exactly once when the WebSocket open event fires'); }); + +// --- Touch scroll source-inspection tests --- + +const terminalSrc = fs.readFileSync( + new URL('../terminal.js', import.meta.url), 'utf8' +); + +test('terminal.js registers touchmove handler on terminal-container', () => { + // Source inspection: verify the touch scroll IIFE is present and uses + // e.preventDefault() (passive: false is required for prevent default) + assert.ok(terminalSrc.includes('touchmove'), + 'touchmove listener must be present'); + assert.ok(terminalSrc.includes('e.preventDefault'), + 'must call preventDefault to prevent page scroll while swiping inside terminal'); + assert.ok(terminalSrc.includes('scrollLines'), + 'must call _term.scrollLines to scroll terminal'); + assert.ok(terminalSrc.includes('passive: false'), + 'touchmove must be non-passive to allow preventDefault'); +}); + +test('terminal.js touchstart and touchend are passive', () => { + // touchstart and touchend should be passive: true for performance + // (they don't need preventDefault) + const touchstartPassive = terminalSrc.match(/touchstart[\s\S]*?passive:\s*(true|false)/); + const touchendPassive = terminalSrc.match(/touchend[\s\S]*?passive:\s*(true|false)/); + assert.ok(touchstartPassive, 'touchstart listener must declare passive'); + assert.ok(touchendPassive, 'touchend listener must declare passive'); +}); diff --git a/muxplex/tests/test_frontend_css.py b/muxplex/tests/test_frontend_css.py index 3eb695e..f92d81b 100644 --- a/muxplex/tests/test_frontend_css.py +++ b/muxplex/tests/test_frontend_css.py @@ -587,3 +587,20 @@ def test_mobile_active_tier_targets_tile_body_pre_not_tile_pre(): assert ".session-tile--tier-active .tile-pre" not in css, ( ".tile-pre is a dead class — selector must be removed" ) + + +def test_sidebar_list_has_touch_action_pan_y(): + """Regression: without touch-action:pan-y .sidebar-list won't scroll on mobile touch. + + Mobile browsers need an explicit touch-action:pan-y declaration to allow + vertical panning on an overflow-y:auto element when adjacent content (like + xterm.js canvas) may be consuming touch events. + """ + css = read_css() + block = _extract_rule_block(css, ".sidebar-list {") + assert "touch-action: pan-y" in block, ( + ".sidebar-list must have touch-action:pan-y for mobile vertical scroll" + ) + assert "overscroll-behavior: contain" in block, ( + ".sidebar-list must have overscroll-behavior:contain to prevent scroll chaining" + )