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.
This commit is contained in:
Brian Krabach
2026-03-28 14:37:57 -07:00
parent 4ffdc1bd99
commit 3d91bd5c68
4 changed files with 87 additions and 0 deletions
+2
View File
@@ -427,6 +427,8 @@ body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; 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 { .sidebar-collapse-btn {
+39
View File
@@ -246,3 +246,42 @@ function closeTerminal() {
// ─── Expose to app.js ───────────────────────────────────────────────────────── // ─── Expose to app.js ─────────────────────────────────────────────────────────
window._openTerminal = openTerminal; window._openTerminal = openTerminal;
window._closeTerminal = closeTerminal; 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 });
})();
+29
View File
@@ -5,6 +5,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import fs from 'node:fs';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
@@ -717,3 +718,31 @@ test('terminal is auto-focused when WebSocket opens', () => {
assert.strictEqual(t.focusCallCount, 1, assert.strictEqual(t.focusCallCount, 1,
'_term.focus() should be called exactly once when the WebSocket open event fires'); '_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');
});
+17
View File
@@ -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, ( assert ".session-tile--tier-active .tile-pre" not in css, (
".tile-pre is a dead class — selector must be removed" ".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"
)