mobux 0.4.0

A touch-friendly tmux web UI for unhinged people who run terminal sessions from their phone while walking the dog
# Mobux — Features to layer back on top of fb6c0e8

## Known-good base state: commit fb6c0e8
This is the last commit where touch scrolling reliably works on the phone.
It has: scroll (vertical swipe with iOS/BetterScroll momentum physics),
horizontal swipe to switch tmux windows, double-tap for keyboard,
two-finger pull-to-reload, reconnect on touch, no toolbar.

Every attempt to add features on top has broken scrolling. The root cause
is unknown — the changes look innocent but something breaks. Might be:
browser cache (despite cache-buster), JS parse issue, CSS interaction,
or a subtle xterm.js state corruption. Needs careful investigation.

---

## Feature 1: Loading screen with CS quotes

### What it does
Shows a random quote from a CS pioneer while tmux dumps scrollback on connect.
Quote fades out once data settles, revealing the terminal at the bottom.

### Quotes list
- "Simplicity is prerequisite for reliability." — Edsger W. Dijkstra
- "If debugging is the process of removing bugs, then programming must be the process of putting them in." — Edsger W. Dijkstra
- "The Analytical Engine weaves algebraical patterns just as the Jacquard loom weaves flowers and leaves." — Ada Lovelace
- "We can only see a short distance ahead, but we can see plenty there that needs to be done." — Alan Turing
- "Those who can imagine anything, can create the impossible." — Alan Turing
- "The most dangerous phrase in the language is: we've always done it this way." — Grace Hopper
- "The best way to predict the future is to invent it." — Alan Kay
- "Premature optimization is the root of all evil." — Donald Knuth
- "Talk is cheap. Show me the code." — Linus Torvalds
- "Controlling complexity is the essence of computer programming." — Brian Kernighan
- "Any sufficiently advanced technology is indistinguishable from magic." — Arthur C. Clarke
- "Information is the resolution of uncertainty." — Claude Shannon

### Implementation notes
- HTML: `<div id="loadquote"><q id="quote"></q><br><cite id="qauthor"></cite></div>`
- The div MUST be at z-index 5 or lower (touch overlay is z-index 10)
- NEVER put anything at z-index > 10 over the terminal — it breaks touch
- DO NOT use `visibility: hidden` on #terminal — it breaks wheel event dispatch
- The quote div has opaque background (var(--bg)) so it covers the scroll storm
- JS picks a random quote on page load and sets textContent
- Debounced reveal: `scheduleReveal()` called in ws.onmessage, resets 800ms timer
- After 800ms of no data: `scrollToBottom()`, fade out quote (opacity 0), remove after 300ms
- Remove the old `setTimeout(() => term.scrollToBottom(), 500)` — reveal handles it

### CSS
```css
#loadquote {
  position: absolute;
  inset: 0;
  z-index: 5;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  background: var(--bg);
  color: var(--muted);
  font-size: 16px;
  padding: 24px;
  text-align: center;
  gap: 8px;
  transition: opacity 0.3s;
}
#loadquote q { font-style: italic; color: var(--text); max-width: 500px; line-height: 1.5; }
#loadquote cite { color: var(--primary); font-size: 14px; }
```

---

## Feature 2: Pinch-to-zoom font size

### What it does
Two-finger pinch on the terminal changes font size (8px min, 32px max).
Distinguishes pinch from two-finger-pull-to-reload using a three-phase classifier.

### Three-phase gesture classifier
1. `'two'` — two fingers down, undecided. Nothing happens yet.
2. If finger distance changes > 25% of initial → locks to `'pinch'` (font zoom)
3. If both fingers move together > 30px → locks to `'twopull'` (reload)
4. Once locked, stays locked for the gesture. No cross-contamination.

### Timing constants (researched from iOS/Android/BetterScroll)
- Pinch threshold: 25% scale change from 1.0 (iOS UIPinchGestureRecognizer uses 15-20%)
- Pull threshold: 30px of parallel movement
- Font size range: 8-32px
- On font change: `term.options.fontSize = newSize; sendResize();`
  (DO NOT call term.refresh() — sendResize handles the redraw)

### State variables needed
```js
let pinchStartDist = 0;       // Math.hypot of initial finger distance
let pinchStartFontSize = 0;   // term.options.fontSize at gesture start
let wasTwoFinger = false;     // guards single-finger events after two-finger
```

### wasTwoFinger guard
When lifting one finger after a two-finger gesture, the browser fires
single-finger touchmove events. Without the guard, these trigger scrolling.
- Set `wasTwoFinger = true` when two fingers detected in touchstart
- Set `wasTwoFinger = false` only on fresh one-finger touchstart
- Check `wasTwoFinger` in single-finger touchmove: skip if true

### touchstart changes
```js
if (e.touches.length === 2) {
  const fdx = e.touches[0].pageX - e.touches[1].pageX;
  const fdy = e.touches[0].pageY - e.touches[1].pageY;
  pinchStartDist = Math.hypot(fdx, fdy);
  pinchStartFontSize = term.options.fontSize;
  startY = (e.touches[0].pageY + e.touches[1].pageY) / 2;
  gesture = 'two';
  wasTwoFinger = true;
  return;
}
// ... existing one-finger code ...
wasTwoFinger = false;
```

### touchmove two-finger block
```js
if (e.touches.length === 2 && (gesture === 'two' || gesture === 'pinch' || gesture === 'twopull')) {
  const fdx = e.touches[0].pageX - e.touches[1].pageX;
  const fdy = e.touches[0].pageY - e.touches[1].pageY;
  const dist = Math.hypot(fdx, fdy);
  const midY = (e.touches[0].pageY + e.touches[1].pageY) / 2;
  const pull = midY - startY;
  const scale = dist / pinchStartDist;

  if (gesture === 'two') {
    if (Math.abs(scale - 1.0) > 0.25) gesture = 'pinch';
    else if (Math.abs(pull) > 30) gesture = 'twopull';
    else return; // undecided
  }

  if (gesture === 'pinch') {
    const newSize = Math.round(Math.max(8, Math.min(32, pinchStartFontSize * scale)));
    if (newSize !== term.options.fontSize) {
      term.options.fontSize = newSize;
      sendResize();
    }
  }

  if (gesture === 'twopull') {
    // existing pull-to-reload feedback
  }
  return;
}

// Single-finger — skip if was two-finger
if (e.touches.length !== 1 || !gesture || wasTwoFinger) return;
```

### touchend additions
```js
if (gesture === 'two') { gesture = null; return; }
if (gesture === 'twopull') { /* existing reload logic */ gesture = null; return; }
if (gesture === 'pinch') { gesture = null; return; }
// ... existing tap/scroll/hswipe code ...
```

---

## Feature 3: Remove bottom toolbar
- Delete the `<nav class="term-toolbar">` from the HTML
- Remove the `40` fallback in resize calculation: use `window.innerHeight` directly
- All interaction through gestures (scroll, swipe, double-tap, pinch, pull)

---

## Feature 4: Auto-reconnect on touch
- Refactor WS setup into `connect()` function inside the async IIFE
- Expose `reconnect()` at module scope
- `reconnect()`: if ws not OPEN, close stale socket, call connect()
- Called on every touchstart on the overlay
- Silent close/error handlers (no "disconnected" message)

---

## Investigation needed
Every time features 1 or 2 are added, scrolling breaks on the phone.
The smoke test (puppeteer headless) passes every time. This suggests:
- It might be a mobile-specific issue (touch event timing, browser behavior)
- The smoke test needs a touch-scroll simulation test
- Maybe add a puppeteer test that simulates touch events and checks viewport.scrollTop changes
- Or the phone is aggressively caching despite the ?v= cache buster