diff --git a/docs/superpowers/plans/2026-08-08-mobile-flash-and-keybar.md b/docs/superpowers/plans/2026-08-08-mobile-flash-and-keybar.md
new file mode 100644
index 0000000..5eb9716
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-08-mobile-flash-and-keybar.md
@@ -0,0 +1,929 @@
+# Mobile Flash Fix + Key Bar Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Kill the both-axes shrink flash when the phone keyboard opens, and give touch devices an on-screen key bar (Esc, Tab, sticky Ctrl, mode-aware arrows) so TUIs like Claude Code are usable from a phone.
+
+**Architecture:** (1) `relayout` in the terminal view currently scales the whole surface down whenever the pty stops fitting in either axis; when only *rows* stop fitting — exactly what a keyboard sliding over the pane does — it will now keep the surface one-to-one and let the bottom rows clip for the settle window, so nothing moves horizontally. (2) A new `lib/keys.ts` owns escape-sequence encoding (CSI vs SS3 arrows per DECCKM, Ctrl chords, sticky-Ctrl byte transform); a dumb `KeyBar` component renders chips inside the terminal pane on coarse-pointer devices; the Terminal effect wires presses into the same guarded input path keystrokes use. The emulator seam gains one method, `applicationCursorKeys()`, so arrows follow the mode the running program set.
+
+**Tech Stack:** React + xterm.js (v6, `term.modes.applicationCursorKeysMode`) + vitest/jsdom in `web/`.
+
+## Global Constraints
+
+- Tailwind scans **prose** in every file under `web/` outside `*.test.*`, `src/testing/`, `src/client/` and `*.go` — a single English word that is also a utility name (even in a comment; even, for some words, as a bare identifier — `transform` was measured to leak) can compile a stray CSS rule and fail `web/src/styles.build.test.ts`. New scanned files in this plan: `web/src/lib/keys.ts`, `web/src/components/key-bar.tsx`. Known-dangerous words to keep out of their prose: `resize` (quoted), `transform`, `shrink`, `blur`, `collapse`, `grid`, `hidden`, `fixed`, `static`, `table`, `container`, `filter`, `outline`, `rounded`, `running`, `underline`, `truncate`, `visible`, `inline`, `isolate`. The guard measures build output, so after any edit to scanned sources run the full web suite (`cd web && npx vitest run` — its `styles.build.test.ts` does a real vite build, minutes; be patient) and reword whatever leaks.
+- Test commands: `cd web && npx vitest run src/components/terminal.test.tsx` (or another file) focused; `cd web && npx vitest run` full.
+- If a `web/package-lock.json` appears after npx runs, delete it; never stage it.
+- Never edit `web/dist/`.
+- Commit style: conventional prefix, body explains why, matching repo history.
+- Base branch: `main`.
+
+---
+
+### Task 1: Clip instead of scale when only rows stop fitting
+
+Today `relayout` (web/src/components/terminal.tsx) takes the one-to-one branch only when `want.cols >= dims.cols && want.rows >= dims.rows`, and otherwise scales the whole surface by a uniform factor. When the phone keyboard opens, the pane loses half its height for the ~150ms settle window before the pty follows, and the uniform factor pinches *both* axes — the reported "flashes to half horizontally". Fix: the one-to-one branch triggers whenever every **column** fits; rows overflowing alone means the bottom rows clip briefly (the pane has `overflow-hidden`), which the keyboard animation covers.
+
+**Files:**
+- Modify: `web/src/components/terminal.tsx` (the two-branch layout at the end of `relayout`, currently `if (want.cols >= dims.cols && want.rows >= dims.rows) {`)
+- Test: `web/src/components/terminal.test.tsx` (inside `describe('the sizing policy', ...)`)
+
+**Interfaces:**
+- Consumes: existing test harness — `paneOf`, `resizeObservers`, `mountTerminal`, `attached`, `surfaceEl`, `GUTTER_PX`.
+- Produces: nothing later tasks rely on.
+
+- [ ] **Step 1: Write the failing test**
+
+Add inside `describe('the sizing policy', ...)` in `web/src/components/terminal.test.tsx`:
+
+```tsx
+ it('stays one-to-one when only rows stop fitting, so a keyboard cannot pinch the width', async () => {
+ const observers = resizeObservers()
+ const box = paneOf(800 + GUTTER_PX, 408)
+ const { sock, em } = mountTerminal((e) => (
+
+ ))
+ // 80x24 rendered at 800x408 puts a cell at 10 x 17.
+ em.live().measured = { width: 800, height: 408 }
+ act(() => sock.emitControl(attached({ ref: 1, id: 's1', cols: 80, rows: 24, primary: true })))
+
+ // The keyboard takes half the pane's height: same columns, half the rows.
+ box.mockReturnValue({ width: 800 + GUTTER_PX, height: 204 } as DOMRect)
+ act(() => observers.fire())
+
+ // The settled report proves the whole relayout → settle path ran…
+ await waitFor(() =>
+ expect(sock.ofType('resize')).toContainEqual({
+ type: 'resize',
+ ref: 1,
+ cols: 80,
+ rows: 12,
+ primary: true,
+ }),
+ )
+ // …and through all of it the surface was never scaled or resized: the
+ // bottom rows clip behind the keyboard until the pty follows, and the
+ // width never moves.
+ expect(surfaceEl().style.scale).toBe('')
+ expect(surfaceEl().style.width).toBe('')
+ expect(surfaceEl().style.height).toBe('')
+ })
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd web && npx vitest run src/components/terminal.test.tsx -t 'only rows stop fitting'`
+Expected: FAIL — under the current branch condition the rows-only overflow takes the scaling path, so `surface.style.scale` is a number, not `''`.
+
+- [ ] **Step 3: Change the branch condition**
+
+In `relayout` in `web/src/components/terminal.tsx`, replace the first branch's condition and comment (currently "The pty fits this pane — whatever view set the size, this one can show it whole — so the surface simply fills the pane and nothing is transformed."):
+
+```ts
+ if (want.cols >= dims.cols) {
+ // Every column fits, so the surface lays out one-to-one whatever the
+ // row count says. Rows overflowing alone is almost always this view's
+ // own keyboard sliding over the pane: for the settle window the bottom
+ // rows clip behind it and then the pty takes the new height. Scaling
+ // here instead used to pinch both axes for that window — a lurch on
+ // every keyboard open. The cost is deliberate: a view whose columns
+ // fit while its rows do not shows the top of the screen until the pty
+ // follows, and in the rare enduring cross-device shape of that kind,
+ // scrollback still reaches what the pane cannot.
+ surface.style.removeProperty('width')
+ surface.style.removeProperty('height')
+ surface.style.removeProperty('scale')
+ return
+ }
+
+ // Columns overflow: a wider view is setting the size, and columns
+ // clipping would amputate lines mid-word. Lay the surface out at the
+ // screen's true size and scale the whole thing down, rather than
+ // reflowing text.
+```
+
+Keep the rest of the scaling branch (the gutter comment and the three style assignments) unchanged.
+
+- [ ] **Step 4: Run test to verify it passes, then the file, then the full suite**
+
+Run: `cd web && npx vitest run src/components/terminal.test.tsx -t 'only rows stop fitting'` → PASS.
+Run: `cd web && npx vitest run src/components/terminal.test.tsx` → PASS (the existing `scales its surface to the larger view…` test still passes: its overflow is in columns).
+Run: `cd web && npx vitest run` → PASS, including `styles.build.test.ts` (the rewritten comment is scanned prose — if the guard names a leaked word, reword the comment and re-run).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add web/src/components/terminal.tsx web/src/components/terminal.test.tsx
+git commit -m "fix(web): keep the width still while the keyboard settles
+
+Rows-only overflow now renders one-to-one with the bottom rows briefly
+clipped instead of scaling the whole surface down: the uniform factor
+pinched both axes for the settle window, a visible lurch on every
+keyboard open. Scaling remains for column overflow, where clipping
+would amputate lines."
+```
+
+---
+
+### Task 2: Key sequences library
+
+Pure functions for what the bar sends. Arrows are mode-aware — a full-screen program that sets DECCKM (vim, less, Claude Code's TUI) expects SS3 (`ESC O A`), a shell expects CSI (`ESC [ A`) — and Ctrl-chorded arrows are `ESC [ 1 ; 5 X` regardless of mode, which is what xterm itself emits. Sticky Ctrl over typed text is a byte transform: `c` → 0x03.
+
+**Files:**
+- Create: `web/src/lib/keys.ts`
+- Test: `web/src/lib/keys.test.ts`
+
+**Interfaces:**
+- Consumes: nothing.
+- Produces (Task 3 relies on these exact names): `type BarKey = 'esc' | 'tab' | 'up' | 'down' | 'left' | 'right'`; `barKeyBytes(key: BarKey, opts: { appCursor: boolean; ctrl: boolean }): Uint8Array`; `ctrlTransform(bytes: Uint8Array): Uint8Array | null`.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `web/src/lib/keys.test.ts`:
+
+```ts
+import { describe, expect, it } from 'vitest'
+import { barKeyBytes, ctrlTransform } from './keys'
+
+const text = (b: Uint8Array) => new TextDecoder().decode(b)
+
+describe('barKeyBytes', () => {
+ it('encodes arrows as CSI when the program has not asked for more', () => {
+ expect(text(barKeyBytes('up', { appCursor: false, ctrl: false }))).toBe('\x1b[A')
+ expect(text(barKeyBytes('down', { appCursor: false, ctrl: false }))).toBe('\x1b[B')
+ expect(text(barKeyBytes('right', { appCursor: false, ctrl: false }))).toBe('\x1b[C')
+ expect(text(barKeyBytes('left', { appCursor: false, ctrl: false }))).toBe('\x1b[D')
+ })
+
+ it('switches arrows to SS3 under application cursor keys', () => {
+ expect(text(barKeyBytes('up', { appCursor: true, ctrl: false }))).toBe('\x1bOA')
+ expect(text(barKeyBytes('left', { appCursor: true, ctrl: false }))).toBe('\x1bOD')
+ })
+
+ it('encodes Ctrl-arrows as modified CSI, whatever the cursor mode', () => {
+ // xterm sends CSI 1;5 for ctrl-arrows even in application mode.
+ expect(text(barKeyBytes('up', { appCursor: false, ctrl: true }))).toBe('\x1b[1;5A')
+ expect(text(barKeyBytes('right', { appCursor: true, ctrl: true }))).toBe('\x1b[1;5C')
+ })
+
+ it('sends esc and tab as their single bytes, ctrl or not', () => {
+ expect(text(barKeyBytes('esc', { appCursor: false, ctrl: false }))).toBe('\x1b')
+ expect(text(barKeyBytes('tab', { appCursor: true, ctrl: true }))).toBe('\x09')
+ })
+})
+
+describe('ctrlTransform', () => {
+ const of = (...b: number[]) => Uint8Array.from(b)
+
+ it('folds letters onto control codes, either case', () => {
+ expect(ctrlTransform(of(0x63))).toEqual(of(0x03)) // c → ETX (Ctrl+C)
+ expect(ctrlTransform(of(0x43))).toEqual(of(0x03)) // C too
+ expect(ctrlTransform(of(0x64))).toEqual(of(0x04)) // d → EOT
+ })
+
+ it('covers the punctuation controls a terminal actually uses', () => {
+ expect(ctrlTransform(of(0x5b))).toEqual(of(0x1b)) // [ → ESC
+ expect(ctrlTransform(of(0x20))).toEqual(of(0x00)) // space → NUL
+ expect(ctrlTransform(of(0x3f))).toEqual(of(0x7f)) // ? → DEL
+ })
+
+ it('declines anything it cannot fold', () => {
+ expect(ctrlTransform(of(0x31))).toBeNull() // digit
+ expect(ctrlTransform(new TextEncoder().encode('é'))).toBeNull() // multi-byte
+ expect(ctrlTransform(new TextEncoder().encode('ls'))).toBeNull() // paste
+ })
+})
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd web && npx vitest run src/lib/keys.test.ts`
+Expected: FAIL — `./keys` does not exist.
+
+- [ ] **Step 3: Implement `web/src/lib/keys.ts`**
+
+```ts
+/** The keys the on-screen bar offers. Ctrl is a modifier, not a key here. */
+export type BarKey = 'esc' | 'tab' | 'up' | 'down' | 'left' | 'right'
+
+const encoder = new TextEncoder()
+
+/** VT arrow finals: CSI/SS3 A B C D are up, down, right, left — in that order. */
+const ARROW_FINAL = { up: 'A', down: 'B', right: 'C', left: 'D' } as const
+
+/**
+ * The bytes a bar key sends.
+ *
+ * Arrows follow DECCKM — CSI for a shell, SS3 once a full-screen program has
+ * asked for application cursor keys — because a bar that always sent CSI
+ * would move the cursor in vim and type `A` in less. Ctrl-arrows are the
+ * modified CSI form whatever the mode, which is what xterm itself emits.
+ * Esc and tab are single bytes with no Ctrl form worth sending.
+ */
+export function barKeyBytes(key: BarKey, opts: { appCursor: boolean; ctrl: boolean }): Uint8Array {
+ if (key === 'esc') return encoder.encode('\x1b')
+ if (key === 'tab') return encoder.encode('\x09')
+ const fin = ARROW_FINAL[key]
+ if (opts.ctrl) return encoder.encode(`\x1b[1;5${fin}`)
+ return encoder.encode(opts.appCursor ? `\x1bO${fin}` : `\x1b[${fin}`)
+}
+
+/**
+ * Fold one typed key onto its control code, for the bar's sticky Ctrl.
+ *
+ * Touch keyboards carry no Ctrl, so the bar arms one and the next keystroke
+ * lands here. Null means "not foldable" — a digit, a paste, a multi-byte
+ * character — and the caller sends the bytes untouched; the arming is spent
+ * either way, like a real sticky modifier.
+ */
+export function ctrlTransform(bytes: Uint8Array): Uint8Array | null {
+ if (bytes.length !== 1) return null
+ const b = bytes[0]!
+ if (b === 0x20) return Uint8Array.of(0x00) // Ctrl+Space
+ if (b === 0x3f) return Uint8Array.of(0x7f) // Ctrl+?
+ if (b >= 0x61 && b <= 0x7a) return Uint8Array.of(b & 0x1f) // a-z
+ if (b >= 0x40 && b <= 0x5f) return Uint8Array.of(b & 0x1f) // @, A-Z, [ \ ] ^ _
+ return null
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd web && npx vitest run src/lib/keys.test.ts`
+Expected: PASS (11 assertions across 7 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add web/src/lib/keys.ts web/src/lib/keys.test.ts
+git commit -m "feat(web): encode what an on-screen key bar sends
+
+Arrows follow DECCKM (CSI for a shell, SS3 for vim and friends),
+Ctrl-arrows are the modified CSI form either way, and a sticky Ctrl
+folds the next typed byte onto its control code. Pure functions ahead
+of the bar that will press them."
+```
+
+---
+
+### Task 3: The key bar — seam method, component, wiring
+
+Three pieces: the emulator seam gains `applicationCursorKeys()` (xterm: `term.modes.applicationCursorKeysMode`; fake: a settable field); a dumb `KeyBar` component renders the chips; the Terminal component shows it on coarse-pointer devices, reserves bottom room for it inside the inset, and routes presses and the sticky Ctrl through the same ref/mute-guarded path typed input uses.
+
+**Files:**
+- Modify: `web/src/emulator/types.ts` (add one method to `Emulator`)
+- Modify: `web/src/emulator/xterm.ts` (implement it)
+- Modify: `web/src/testing/emulator.ts` (fake: `appCursor` field + method)
+- Create: `web/src/components/key-bar.tsx`
+- Modify: `web/src/components/terminal.tsx` (state, onData transform, `sendKey` action, render)
+- Test: `web/src/components/terminal.test.tsx` (new `describe('the key bar', ...)`)
+
+**Interfaces:**
+- Consumes: `BarKey`, `barKeyBytes`, `ctrlTransform` from `@/lib/keys` (Task 2, exact signatures there).
+- Produces: `Emulator.applicationCursorKeys(): boolean`; `KeyBar(props: { ctrl: boolean; onCtrl: () => void; onKey: (key: BarKey) => void })`; `FakeEmulator.appCursor: boolean`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Add to `web/src/components/terminal.test.tsx` (top-level import: add `fireEvent` is already imported; nothing new needed):
+
+```tsx
+ describe('the key bar', () => {
+ /** jsdom has no matchMedia; a coarse pointer is claimed explicitly. */
+ function coarsePointer() {
+ vi.stubGlobal('matchMedia', (query: string) => ({
+ matches: query.includes('coarse'),
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ }))
+ }
+ const bar = () => document.querySelector('[data-flue-keybar]')
+ const key = (label: string) =>
+ Array.from(document.querySelectorAll('[data-flue-keybar] button')).find(
+ (b) => b.textContent === label,
+ )!
+
+ it('exists only for touch', () => {
+ const { sock } = mountTerminal((e) => )
+ act(() => sock.emitControl(attached({ ref: 1, id: 's1' })))
+ expect(bar()).toBeNull()
+ })
+
+ it('sends CSI arrows for a shell and SS3 once the program asks', () => {
+ coarsePointer()
+ const { sock, em } = mountTerminal((e) => (
+
+ ))
+ act(() => sock.emitControl(attached({ ref: 1, id: 's1' })))
+
+ fireEvent.pointerDown(key('↑'))
+ expect(sock.input()).toEqual([{ ref: 1, text: '\x1b[A' }])
+
+ em.live().appCursor = true
+ fireEvent.pointerDown(key('↓'))
+ expect(sock.input()).toEqual([
+ { ref: 1, text: '\x1b[A' },
+ { ref: 1, text: '\x1bOB' },
+ ])
+ })
+
+ it('arms Ctrl for exactly one following keystroke', () => {
+ coarsePointer()
+ const { sock, em } = mountTerminal((e) => (
+
+ ))
+ act(() => sock.emitControl(attached({ ref: 1, id: 's1' })))
+
+ fireEvent.pointerDown(key('ctrl'))
+ expect(key('ctrl').getAttribute('aria-pressed')).toBe('true')
+ act(() => em.live().send('c'))
+ act(() => em.live().send('c'))
+ expect(sock.input()).toEqual([
+ { ref: 1, text: '\x03' },
+ { ref: 1, text: 'c' },
+ ])
+ expect(key('ctrl').getAttribute('aria-pressed')).toBe('false')
+ })
+
+ it('chords Ctrl with an arrow', () => {
+ coarsePointer()
+ const { sock } = mountTerminal((e) => )
+ act(() => sock.emitControl(attached({ ref: 1, id: 's1' })))
+
+ fireEvent.pointerDown(key('ctrl'))
+ fireEvent.pointerDown(key('→'))
+ expect(sock.input()).toEqual([{ ref: 1, text: '\x1b[1;5C' }])
+ expect(key('ctrl').getAttribute('aria-pressed')).toBe('false')
+ })
+
+ it('drops bar keys pressed before the attach comes back', () => {
+ coarsePointer()
+ const { sock } = mountTerminal((e) => )
+ fireEvent.pointerDown(key('esc'))
+ expect(sock.input()).toEqual([])
+ })
+
+ it('reserves bottom room in the inset so the bar covers no rows', () => {
+ coarsePointer()
+ mountTerminal((e) => )
+ expect(inset().className).toContain('bottom-16')
+ })
+ })
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `cd web && npx vitest run src/components/terminal.test.tsx -t 'key bar'`
+Expected: FAIL — no `[data-flue-keybar]` renders (and `appCursor` does not exist on the fake yet). The `exists only for touch` case may pass vacuously; the rest must fail.
+
+- [ ] **Step 3: Add the seam method**
+
+`web/src/emulator/types.ts`, append to the `Emulator` interface (after `answerQueries`); also amend the interface's opening doc comment ("talks to these ten methods") to not count methods — reword that clause to "talks to this interface", since the count is now wrong either way:
+
+```ts
+ /**
+ * Whether the program has asked for application cursor keys (DECCKM).
+ *
+ * The on-screen key bar synthesises arrow presses, and an arrow's encoding
+ * is the program's choice, not the bar's: CSI moves history at a shell,
+ * SS3 is what vim and friends expect once they have set the mode. Reading
+ * it here keeps the bar as mode-honest as a hardware keyboard through
+ * xterm would be.
+ */
+ applicationCursorKeys(): boolean
+```
+
+`web/src/emulator/xterm.ts`, inside the returned emulator object (beside `answerQueries`):
+
+```ts
+ applicationCursorKeys: () => term.modes.applicationCursorKeysMode,
+```
+
+`web/src/testing/emulator.ts`: add `appCursor: boolean` to the `FakeEmulator` interface with the doc `/** What applicationCursorKeys() reports; set by hand like measured. */`, initialise `appCursor: false` in the object literal, and add the method:
+
+```ts
+ applicationCursorKeys: () => self.appCursor,
+```
+
+- [ ] **Step 4: Create `web/src/components/key-bar.tsx`**
+
+```tsx
+import type { BarKey } from '@/lib/keys'
+import { cn } from '@/lib/utils'
+
+const KEYS: ReadonlyArray<{ key: BarKey; label: string; name: string }> = [
+ { key: 'esc', label: 'esc', name: 'Escape' },
+ { key: 'tab', label: 'tab', name: 'Tab' },
+ { key: 'left', label: '←', name: 'Arrow left' },
+ { key: 'down', label: '↓', name: 'Arrow down' },
+ { key: 'up', label: '↑', name: 'Arrow up' },
+ { key: 'right', label: '→', name: 'Arrow right' },
+]
+
+/**
+ * The touch device's missing keys, floated over the terminal's bottom edge.
+ *
+ * Presses land on pointerdown, and the handler prevents the default so the
+ * press never takes focus from xterm's textarea — losing it would close the
+ * very keyboard the bar exists to work beside. Ctrl is sticky: one press
+ * arms it for the next key, bar or typed, and the Terminal owns that state
+ * because the fold happens on the input path, not here.
+ */
+export function KeyBar(props: {
+ ctrl: boolean
+ onCtrl: () => void
+ onKey: (key: BarKey) => void
+}) {
+ const chip = 'rounded-md px-2.5 py-1.5 font-mono text-sm/4 transition-colors select-none'
+ return (
+
+
+ {KEYS.map((k) => (
+
+ ))}
+
+ )
+}
+```
+
+(Note the `label` and the visible text are what the tests select on: `esc`, `tab`, `ctrl`, `←`, `↓`, `↑`, `→`. The `sr-only` span rides inside the button, so `textContent` for arrows is `'↑Arrow up'` — **therefore the test's `key()` helper must match with `startsWith`, not equality.** Use `b.textContent?.startsWith(label)` in Step 1's helper; this note wins over the equality form if you wrote that first.)
+
+- [ ] **Step 5: Wire into Terminal**
+
+In `web/src/components/terminal.tsx`:
+
+Imports:
+
+```ts
+import { KeyBar } from '@/components/key-bar'
+import { barKeyBytes, ctrlTransform, type BarKey } from '@/lib/keys'
+```
+
+State, beside the existing `mode` state:
+
+```ts
+ // Coarse pointer once per mount: whether this device's primary pointer is a
+ // finger decides the key bar's existence, and a pointer does not change
+ // class mid-session in any way worth re-rendering for.
+ const [coarse] = useState(() => globalThis.matchMedia?.('(pointer: coarse)')?.matches ?? false)
+ // The sticky Ctrl: state for the chip's pressed look, a ref for the input
+ // path, which lives inside the effect and must read it without re-running.
+ const [ctrlArmed, setCtrlArmed] = useState(false)
+ const ctrlArmedRef = useRef(ctrlArmed)
+ ctrlArmedRef.current = ctrlArmed
+```
+
+Replace the `emulator.onData` registration body:
+
+```ts
+ emulator.onData((bytes) => {
+ // No ref, no destination — and no input while the backlog replays.
+ if (ref === null || consumed < muteUntil) return
+ let out = bytes
+ if (ctrlArmedRef.current) {
+ // The bar's sticky Ctrl folds this keystroke, and is spent on it
+ // whether or not it could fold — like a real sticky modifier.
+ out = ctrlTransform(bytes) ?? bytes
+ setCtrlArmed(false)
+ }
+ client.sendInput(ref, out)
+ })
+```
+
+Extend the `actionsRef` type and object with `sendKey` (type field: `sendKey: (key: BarKey) => void`; object, beside `restart` and `applyTheme`):
+
+```ts
+ sendKey: (key) => {
+ if (ref === null || consumed < muteUntil) return
+ const bytes = barKeyBytes(key, {
+ appCursor: emulator.applicationCursorKeys(),
+ ctrl: ctrlArmedRef.current,
+ })
+ if (ctrlArmedRef.current) setCtrlArmed(false)
+ client.sendInput(ref, bytes)
+ },
+```
+
+Render: the inset div's `className` gains bottom room when the bar exists —
+
+```tsx
+ className={cn(
+ 'absolute inset-3 transition-opacity',
+ coarse && 'bottom-16',
+ phase === 'exited' && 'opacity-60',
+ )}
+```
+
+— and after the inset div's closing tag (before the top-right controls block), render the bar:
+
+```tsx
+ {coarse && (
+ setCtrlArmed((v) => !v)}
+ onKey={(k) => actionsRef.current?.sendKey(k)}
+ />
+ )}
+```
+
+- [ ] **Step 6: Run tests to verify they pass**
+
+Run: `cd web && npx vitest run src/components/terminal.test.tsx`
+Expected: PASS, all describes — the pre-existing tests never stub `matchMedia`, so `coarse` is false and the bar changes nothing for them.
+
+- [ ] **Step 7: Full suite (scanner guard included)**
+
+Run: `cd web && npx vitest run`
+Expected: PASS. `key-bar.tsx` and the `terminal.tsx` edits are scanned prose + markup; the class strings here are all hyphenated or var-referencing (safe shapes), and if `styles.build.test.ts` names a leaked bare word from a comment, reword and re-run. Also `tsc --noEmit` via `npm run lint`.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add web/src/emulator/types.ts web/src/emulator/xterm.ts web/src/testing/emulator.ts web/src/components/key-bar.tsx web/src/components/terminal.tsx web/src/components/terminal.test.tsx
+git commit -m "feat(web): an on-screen key bar for touch devices
+
+Esc, tab, a sticky ctrl and the four arrows, floated over the
+terminal's bottom edge on coarse-pointer devices only. Arrows follow
+DECCKM through a new emulator seam method, ctrl folds the next
+keystroke on the input path, and presses ride pointerdown with the
+default prevented so the soft keyboard stays open. The inset reserves
+the bar's room, so it covers no rows."
+```
+
+---
+
+### Task 4: Momentum for touch scrolling
+
+Drags today translate one-to-one into whole-line `scrollLines()` calls and stop dead the instant the finger lifts — accurate, but rigid. This adds inertia: `touchmove` keeps a short window of `(timeStamp, clientY)` samples, `touchend` turns them into a line-per-second velocity, and a glide loop decays it with iOS-feel friction (`0.998^ms`), emitting whole lines with the fractional carry. A new touch, a pinch-zoom, or unmount cancels the glide.
+
+**Files:**
+- Create: `web/src/lib/glide.ts`
+- Create: `web/src/lib/glide.test.ts`
+- Modify: `web/src/components/terminal.tsx` (touch handlers + cleanup)
+- Test: `web/src/components/terminal.test.tsx` (inside `describe('touch scrolling', ...)`)
+
+**Interfaces:**
+- Consumes: existing touch handlers, `zoomedIn` from `@/lib/viewport`, the `touch()` test helper (extended with a timestamp).
+- Produces: `startGlide(opts: { velocity: number; onLines: (lines: number) => void; raf?: typeof requestAnimationFrame; caf?: typeof cancelAnimationFrame }): () => void` — starts a decaying scroll, returns cancel. Velocity is in lines per second, positive toward newer output, matching `scrollLines`.
+
+- [ ] **Step 1: Write the failing unit test**
+
+Create `web/src/lib/glide.test.ts`:
+
+```ts
+import { describe, expect, it } from 'vitest'
+import { startGlide } from './glide'
+
+/** A hand-cranked animation frame loop. step() advances the clock. */
+function frames() {
+ const queue: Array<{ id: number; cb: (t: number) => void }> = []
+ let nextId = 1
+ let now = 0
+ return {
+ raf: (cb: (t: number) => void) => {
+ const id = nextId++
+ queue.push({ id, cb })
+ return id
+ },
+ caf: (id: number) => {
+ const at = queue.findIndex((f) => f.id === id)
+ if (at >= 0) queue.splice(at, 1)
+ },
+ step(ms: number) {
+ now += ms
+ const due = queue.splice(0, queue.length)
+ for (const f of due) f.cb(now)
+ },
+ pending: () => queue.length,
+ }
+}
+
+describe('startGlide', () => {
+ it('keeps scrolling after the finger lifts, in decaying whole lines', () => {
+ const f = frames()
+ let lines = 0
+ startGlide({ velocity: 60, onLines: (n) => (lines += n), raf: f.raf, caf: f.caf })
+
+ f.step(16) // first frame establishes the clock; no time has passed yet
+ const after1 = lines
+ for (let i = 0; i < 30; i++) f.step(16)
+ const after31 = lines
+
+ expect(after31).toBeGreaterThan(after1)
+ // Half a second of 0.998^ms friction eats most of 60 lines/s: the total
+ // lands well under what the starting velocity alone would cover…
+ expect(after31).toBeLessThan(30)
+ expect(after31).toBeGreaterThan(5)
+ })
+
+ it('carries fractions so slow glides still add up to whole lines', () => {
+ const f = frames()
+ let lines = 0
+ startGlide({ velocity: 4, onLines: (n) => (lines += n), raf: f.raf, caf: f.caf })
+ f.step(16)
+ for (let i = 0; i < 20; i++) f.step(16)
+ // 4 lines/s over ~0.32s is roughly one line — deliverable only by carry.
+ expect(lines).toBeGreaterThanOrEqual(1)
+ })
+
+ it('emits only whole lines, never fractions', () => {
+ const f = frames()
+ const emitted: number[] = []
+ startGlide({ velocity: 25, onLines: (n) => emitted.push(n), raf: f.raf, caf: f.caf })
+ f.step(16)
+ for (let i = 0; i < 10; i++) f.step(16)
+ for (const n of emitted) expect(Number.isInteger(n)).toBe(true)
+ })
+
+ it('scrolls the other way for a negative velocity', () => {
+ const f = frames()
+ let lines = 0
+ startGlide({ velocity: -60, onLines: (n) => (lines += n), raf: f.raf, caf: f.caf })
+ f.step(16)
+ for (let i = 0; i < 10; i++) f.step(16)
+ expect(lines).toBeLessThan(0)
+ })
+
+ it('comes to rest on its own and stops asking for frames', () => {
+ const f = frames()
+ startGlide({ velocity: 10, onLines: () => {}, raf: f.raf, caf: f.caf })
+ for (let i = 0; i < 400 && f.pending(); i++) f.step(16)
+ expect(f.pending()).toBe(0)
+ })
+
+ it('cancel stops it mid-glide', () => {
+ const f = frames()
+ let lines = 0
+ const cancel = startGlide({ velocity: 60, onLines: (n) => (lines += n), raf: f.raf, caf: f.caf })
+ f.step(16)
+ f.step(16)
+ const before = lines
+ cancel()
+ f.step(16)
+ f.step(16)
+ expect(lines).toBe(before)
+ expect(f.pending()).toBe(0)
+ })
+
+ it('declines a velocity too small to glide', () => {
+ const f = frames()
+ startGlide({ velocity: 0.2, onLines: () => {}, raf: f.raf, caf: f.caf })
+ expect(f.pending()).toBe(0)
+ })
+})
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `cd web && npx vitest run src/lib/glide.test.ts`
+Expected: FAIL — `./glide` does not exist.
+
+- [ ] **Step 3: Implement `web/src/lib/glide.ts`**
+
+```ts
+/**
+ * Friction per millisecond. UIKit's "normal" deceleration rate — velocity
+ * multiplied by 0.998 every millisecond — which is the feel a finger that
+ * has used any phone expects, and the reason the constant is not tunable.
+ */
+const FRICTION = 0.998
+
+/** Below this many lines per second a glide has visibly stopped. */
+const REST = 0.5
+
+/**
+ * Scroll on after the finger lifts.
+ *
+ * The drag handlers translate touch motion into whole-line scrolls while the
+ * finger is down; this carries the motion past the lift, decaying an initial
+ * lines-per-second velocity and emitting whole lines with the fraction
+ * carried between frames — the same carry trick the drag itself uses.
+ * Returns a cancel; the caller cancels on the next touch, on a pinch, and
+ * on unmount, because a glide must never outlive the surface it scrolls.
+ */
+export function startGlide(opts: {
+ velocity: number
+ onLines: (lines: number) => void
+ raf?: typeof requestAnimationFrame
+ caf?: typeof cancelAnimationFrame
+}): () => void {
+ const raf = opts.raf ?? requestAnimationFrame
+ const caf = opts.caf ?? cancelAnimationFrame
+ let v = opts.velocity
+ if (Math.abs(v) < REST) return () => {}
+
+ let carry = 0
+ let last: number | null = null
+ let frame = 0
+
+ const tick = (t: number) => {
+ frame = 0
+ if (last !== null) {
+ const dt = t - last
+ // Integrate at the frame's start velocity, then decay: at 60fps the
+ // difference from exact integration is under a line per flick.
+ const delta = (v * dt) / 1000 + carry
+ const lines = Math.trunc(delta)
+ carry = delta - lines
+ if (lines !== 0) opts.onLines(lines)
+ v *= FRICTION ** dt
+ if (Math.abs(v) < REST) return
+ }
+ last = t
+ frame = raf(tick)
+ }
+
+ frame = raf(tick)
+ return () => {
+ if (frame) caf(frame)
+ frame = 0
+ }
+}
+```
+
+- [ ] **Step 4: Run to verify it passes**
+
+Run: `cd web && npx vitest run src/lib/glide.test.ts`
+Expected: PASS (7 tests).
+
+- [ ] **Step 5: Write the failing integration test**
+
+In `web/src/components/terminal.test.tsx`, first extend the `touch()` helper with an explicit clock — jsdom stamps events at creation time, so consecutive dispatches are indistinguishable without it:
+
+```tsx
+function touch(type: 'touchstart' | 'touchmove' | 'touchend', ys: number[], at?: number) {
+ const e = new Event(type, { bubbles: true, cancelable: true })
+ Object.defineProperty(e, 'touches', { value: ys.map((clientY) => ({ clientY })) })
+ if (at !== undefined) Object.defineProperty(e, 'timeStamp', { value: at })
+ return e
+}
+```
+
+(Existing callers pass no `at` and are unaffected.) Then add inside `describe('touch scrolling', ...)` — it already sets `em.live().measured = { width: 800, height: 408 }` and attaches at 80x24, a 17px line:
+
+```tsx
+ it('glides on after a flick, and a new touch stops the glide', () => {
+ const rafCbs: Array<(t: number) => void> = []
+ vi.stubGlobal('requestAnimationFrame', (cb: (t: number) => void) => rafCbs.push(cb))
+ vi.stubGlobal('cancelAnimationFrame', () => {})
+ const surface = surfaceEl()
+
+ // A fast upward drag: 34px (2 lines) per 16ms step.
+ act(() => {
+ surface.dispatchEvent(touch('touchstart', [300], 0))
+ surface.dispatchEvent(touch('touchmove', [266], 16))
+ surface.dispatchEvent(touch('touchmove', [232], 32))
+ surface.dispatchEvent(touch('touchend', [], 48))
+ })
+ const dragged = em.live().scrolled
+ expect(dragged).toBeGreaterThan(0)
+
+ // The glide keeps scrolling without any finger on the glass.
+ act(() => {
+ let t = 48
+ while (rafCbs.length) rafCbs.shift()!(t += 16)
+ // Runs the loop to rest: each callback re-queues the next until the
+ // velocity dies, so draining until empty is running the whole glide.
+ })
+ expect(em.live().scrolled).toBeGreaterThan(dragged)
+
+ // A finger back on the glass pins the content: no glide survives it.
+ act(() => {
+ surface.dispatchEvent(touch('touchstart', [200], 400))
+ })
+ expect(rafCbs.length).toBe(0)
+ })
+```
+
+- [ ] **Step 6: Run to verify it fails**
+
+Run: `cd web && npx vitest run src/components/terminal.test.tsx -t 'glides'`
+Expected: FAIL — `scrolled` does not grow after `touchend`.
+
+- [ ] **Step 7: Wire the glide into the touch handlers**
+
+In `web/src/components/terminal.tsx`, import `startGlide` (beside the other `@/lib` imports):
+
+```ts
+import { startGlide } from '@/lib/glide'
+```
+
+Amend the touch-handler block. New locals beside `touchY`/`touchCarry`:
+
+```ts
+ // The flick record: the last few moves' clocks and positions, enough to
+ // read a release velocity from. Cleared whenever a gesture starts.
+ let flick: Array<{ t: number; y: number }> = []
+ let glide: (() => void) | null = null
+```
+
+`touchStart` gains a cancel and a sample reset (full replacement of the handler):
+
+```ts
+ const touchStart = (e: TouchEvent) => {
+ // A finger on the glass pins the content — any glide in flight ends.
+ glide?.()
+ glide = null
+ flick = []
+ if (e.touches.length !== 1 || zoomedIn(window.visualViewport)) {
+ touchY = null
+ return
+ }
+ touchY = e.touches[0]!.clientY
+ touchCarry = 0
+ }
+```
+
+`touchMove` records a sample after its existing work — add before the final `if (lines !== 0)`:
+
+```ts
+ flick.push({ t: e.timeStamp, y })
+ if (flick.length > 6) flick.shift()
+```
+
+`touchEnd` reads the velocity and starts the glide (full replacement):
+
+```ts
+ const touchEnd = (e: TouchEvent) => {
+ const wasDragging = touchY !== null
+ touchY = null
+ touchCarry = 0
+ // Velocity over the sample window. Two samples and thirty milliseconds
+ // are the floor: a tap or a hold-then-lift reads as no flick at all.
+ const a = flick[0]
+ const b = flick[flick.length - 1]
+ flick = []
+ if (!wasDragging || !a || !b || b.t - a.t < 30) return
+ const dt = (b.t - a.t) / 1000
+ const lps = (a.y - b.y) / lineHeightPx() / dt
+ glide = startGlide({ velocity: lps, onLines: (n) => emulator.scrollLines(n) })
+ void e
+ }
+```
+
+Cleanup: in the effect's return, beside the touch listener removals, add:
+
+```ts
+ glide?.()
+```
+
+- [ ] **Step 8: Run tests, then full suite**
+
+Run: `cd web && npx vitest run src/components/terminal.test.tsx` → PASS (the pre-existing `touchend` tests pass `[]` with no timestamps: `flick` is empty or the span is under 30ms, so no glide starts and their assertions hold).
+Run: `cd web && npx vitest run` → PASS including the scanner guard (new scanned prose in `glide.ts` and `terminal.tsx`; reword on any leak).
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add web/src/lib/glide.ts web/src/lib/glide.test.ts web/src/components/terminal.tsx web/src/components/terminal.test.tsx
+git commit -m "feat(web): let a flick glide the scrollback
+
+Drags scrolled line-for-line and stopped dead at the lift, which reads
+as rigid to a thumb calibrated by every other scrolling surface on a
+phone. A release velocity now decays at UIKit's 0.998-per-millisecond
+rate, emitting whole lines with the fraction carried, and dies at the
+next touch, a pinch, or unmount."
+```
+
+---
+
+## Self-Review Notes
+
+- Spec coverage: flash (Task 1), arrows/Esc/Tab/sticky-Ctrl bar shown only on mobile (Tasks 2–3), mode-aware arrows (Tasks 2–3 via seam method), scroll momentum (Task 4). The user's pty-policy point needs no work.
+- Task 4 sign convention: drag delta is `(touchY - y) / lineHeight` — finger moving down produces negative lines (older). The flick velocity `(a.y - b.y)` preserves exactly that sign, and the glide unit tests pin both directions.
+- Type consistency: `BarKey`/`barKeyBytes`/`ctrlTransform` signatures match across Tasks 2 and 3; `applicationCursorKeys()` name matches across seam, xterm, fake, and wiring; `appCursor` field name matches test usage.
+- Known risk: `pointerDown` events in jsdom — `fireEvent.pointerDown` dispatches a PointerEvent-shaped event that React's `onPointerDown` receives; this is established @testing-library behavior. If an environment quirk surfaces, `fireEvent.mouseDown` on the same handler is NOT equivalent — instead dispatch `new Event('pointerdown', { bubbles: true, cancelable: true })`.
+- Known risk: the `key()` helper matching — resolved in Task 3 Step 4's note (use `startsWith`).
+- Deliberate scope cuts (do not add): no long-press auto-repeat on arrows, no PgUp/PgDn/Home/End, no haptics, no user-configurable keys. All are follow-ups if the bar earns its place.
diff --git a/docs/superpowers/plans/2026-08-08-mobile-terminal-fixes.md b/docs/superpowers/plans/2026-08-08-mobile-terminal-fixes.md
new file mode 100644
index 0000000..265452b
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-08-mobile-terminal-fixes.md
@@ -0,0 +1,593 @@
+# Mobile Terminal Fixes Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make the terminal usable on a phone: pinch-zoom works, the prompt stays visible above the virtual keyboard, and the pty follows the device the user is actually using instead of the largest attached view.
+
+**Architecture:** Three independent fixes. (1) The terminal surface's `touch-action: none` becomes `touch-action: pinch-zoom` so the browser gets the pinch gesture back while single-finger drags still feed the custom scrollback handler. (2) A new `trackVisualViewport` helper sizes the terminal pane to the *visual* viewport, so an iOS keyboard opening shrinks the pane, the ResizeObserver refits, and the pty rows land above the keyboard; while pinch-zoomed it instead hands single-finger panning back to the browser. (3) The daemon's sizing policy changes from "componentwise maximum across views" to "the most recently active view's fit" (tmux `window-size latest` semantics) — activity being input, a size report, a signal, or the attach itself — so a phone gets a phone-sized pty the moment it speaks and the laptop takes it back on its first keystroke.
+
+**Tech Stack:** React + xterm + vitest (jsdom) in `web/`, Go daemon in `internal/daemon/` with `go test`.
+
+## Global Constraints
+
+- Tailwind scans **prose** in every file under `web/` outside `*.test.*`, `src/testing/`, `src/client/` and `*.go` — a quoted single English word that is also a utility name (e.g. `'resize'`) compiles a stray CSS rule and fails `styles.build.test.ts`. Never write the event name `resize` as a quoted string in web sources; use `onresize`/`onscroll` property assignment instead (bare identifiers are not scanner candidates).
+- Comments in `web/` sources are scanned too. After any comment edit under `web/`, run `npm test -- --run src/styles.build.test.ts` (from `web/`) to prove no bare utility leaked.
+- Test commands: `cd web && npx vitest run ` for web; `go test ./internal/daemon/ -run ` for daemon; `make test` for everything.
+- Commit style: conventional prefix (`fix:`, `feat:`), body explains why, matching repo history.
+- Never edit `web/dist/` (build output, committed by `make web`).
+
+---
+
+### Task 1: Give pinch-zoom back to the browser
+
+The terminal surface carries `touch-action: none` (`web/src/styles.css`, `@utility flue-term-surface`), added when touch-drag scrollback landed. `none` also swallows two-finger pinch, which is why the page cannot be zoomed on a phone. `pinch-zoom` keeps single-finger pans out of the browser's hands (the drag handler in `terminal.tsx` still gets them, and it already bails on multi-touch: `e.touches.length !== 1`) while letting two fingers zoom.
+
+**Files:**
+- Modify: `web/src/styles.css` (the `@utility flue-term-surface` block, ~line 296)
+- Test: `web/src/styles.build.test.ts` (add one assertion to the existing `describe('compiled stylesheet')`)
+
+**Interfaces:**
+- Consumes: nothing from other tasks.
+- Produces: the compiled stylesheet contains `touch-action:pinch-zoom` on the surface utility. Task 2's zoomed-pan behavior assumes pinch is browser-handled.
+
+- [ ] **Step 1: Write the failing test**
+
+In `web/src/styles.build.test.ts`, inside `describe('compiled stylesheet', ...)`, add:
+
+```ts
+ it('leaves the pinch gesture to the browser on the terminal surface', () => {
+ // touch-action: none once shipped here and made the page unzoomable on
+ // phones; pinch-zoom keeps single-finger drags for the scrollback
+ // handler while two fingers still zoom.
+ expect(css).toContain('touch-action:pinch-zoom')
+ expect(css).not.toContain('touch-action:none')
+ })
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd web && npx vitest run src/styles.build.test.ts -t 'pinch gesture'`
+Expected: FAIL — compiled css contains `touch-action:none`, not `touch-action:pinch-zoom`. (The build in `beforeAll` takes up to a couple of minutes.)
+
+- [ ] **Step 3: Change the utility**
+
+In `web/src/styles.css`, replace the `flue-term-surface` utility body:
+
+```css
+/* The terminal fills its pane; xterm manages its own internal scrolling. */
+@utility flue-term-surface {
+ block-size: 100%;
+ inline-size: 100%;
+ /* Single-finger gestures are ours: touches become scrollLines() calls
+ * (terminal.tsx), and a browser allowed to pan the page would eat them
+ * first. The pinch stays with the browser — `none` here once made the
+ * page unzoomable on phones, which is too much to take. */
+ touch-action: pinch-zoom;
+}
+```
+
+- [ ] **Step 4: Run the build test file to verify it passes**
+
+Run: `cd web && npx vitest run src/styles.build.test.ts`
+Expected: PASS, including the pre-existing prose-leak guard (the new CSS comment is inside a `.css` file, which is outside the scan perimeter).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add web/src/styles.css web/src/styles.build.test.ts
+git commit -m "fix(web): let two fingers pinch-zoom the terminal
+
+touch-action: none routed every touch to the scrollback handler and
+silently ate the pinch gesture with it. pinch-zoom keeps single-finger
+drags ours and hands the zoom back to the browser."
+```
+
+---
+
+### Task 2: Size the pane to the visual viewport (keyboard, zoomed panning)
+
+The pane is `h-full` of the *layout* viewport. An iOS keyboard shrinks only the *visual* viewport, the page cannot scroll (`overflow-hidden`), so the prompt sits behind the keyboard. Fix: track `window.visualViewport`; at scale ≈ 1, pin the pane's height (and vertical offset) to the visual viewport — the existing ResizeObserver on the inner box then refits and reports smaller rows, and the pty follows. At scale > 1 (pinch-zoomed, Task 1), stop fighting the browser: release `touch-action` on the surface so one finger pans the zoomed page, and leave the pane alone so zooming never refits the pty.
+
+**Files:**
+- Create: `web/src/lib/viewport.ts`
+- Create: `web/src/lib/viewport.test.ts`
+- Modify: `web/src/components/terminal.tsx` (wire into the main effect + cleanup)
+- Modify: `web/index.html` (viewport meta)
+
+**Interfaces:**
+- Consumes: nothing from other tasks (behaviorally pairs with Task 1's `touch-action: pinch-zoom`).
+- Produces: `trackVisualViewport(opts: { pane: HTMLElement; surface: HTMLElement; viewport: ViewportLike | null }): () => void` — installs handlers, applies once immediately, returned function removes handlers and clears every style it set. `ViewportLike` is `{ readonly height: number; readonly offsetTop: number; readonly scale: number; onresize: ((ev: Event) => void) | null; onscroll: ((ev: Event) => void) | null }`.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `web/src/lib/viewport.test.ts`:
+
+```ts
+import { beforeEach, describe, expect, it } from 'vitest'
+import { trackVisualViewport, type ViewportLike } from './viewport'
+
+/** A hand-cranked visualViewport double; fire() plays both handler slots. */
+function fakeViewport(init: { height: number; offsetTop?: number; scale?: number }) {
+ const vv = {
+ height: init.height,
+ offsetTop: init.offsetTop ?? 0,
+ scale: init.scale ?? 1,
+ onresize: null as ViewportLike['onresize'],
+ onscroll: null as ViewportLike['onscroll'],
+ fire() {
+ vv.onresize?.(new Event('x'))
+ },
+ }
+ return vv
+}
+
+describe('trackVisualViewport', () => {
+ let pane: HTMLElement
+ let surface: HTMLElement
+
+ beforeEach(() => {
+ pane = document.createElement('div')
+ surface = document.createElement('div')
+ })
+
+ it('is a no-op without a viewport, as on browsers that lack one', () => {
+ const dispose = trackVisualViewport({ pane, surface, viewport: null })
+ expect(pane.getAttribute('style')).toBeNull()
+ dispose()
+ })
+
+ it('pins the pane to the visual viewport height when the keyboard opens', () => {
+ const vv = fakeViewport({ height: 700 })
+ trackVisualViewport({ pane, surface, viewport: vv })
+ expect(pane.style.height).toBe('700px')
+
+ vv.height = 400 // keyboard up
+ vv.fire()
+ expect(pane.style.height).toBe('400px')
+ })
+
+ it('follows the viewport down the page when focusing scrolls it', () => {
+ const vv = fakeViewport({ height: 400, offsetTop: 120 })
+ trackVisualViewport({ pane, surface, viewport: vv })
+ expect(pane.style.transform).toBe('translateY(120px)')
+ })
+
+ it('releases the surface to the browser while pinch-zoomed', () => {
+ const vv = fakeViewport({ height: 700 })
+ trackVisualViewport({ pane, surface, viewport: vv })
+
+ vv.scale = 2
+ vv.height = 350
+ vv.fire()
+ // One finger must pan the zoomed page, and a zoom is not a layout change:
+ // the pane keeps its unzoomed size so the pty never refits on a pinch.
+ expect(surface.style.touchAction).toBe('auto')
+ expect(pane.style.height).toBe('700px')
+
+ vv.scale = 1
+ vv.height = 700
+ vv.fire()
+ expect(surface.style.touchAction).toBe('')
+ expect(pane.style.height).toBe('700px')
+ })
+
+ it('clears everything it set on dispose', () => {
+ const vv = fakeViewport({ height: 500, offsetTop: 40 })
+ const dispose = trackVisualViewport({ pane, surface, viewport: vv })
+ dispose()
+ expect(pane.getAttribute('style')).toBe('')
+ expect(surface.style.touchAction).toBe('')
+ expect(vv.onresize).toBeNull()
+ expect(vv.onscroll).toBeNull()
+
+ vv.height = 300
+ vv.fire() // a dead handler set would throw or restyle; neither may happen
+ expect(pane.style.height).toBe('')
+ })
+})
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd web && npx vitest run src/lib/viewport.test.ts`
+Expected: FAIL — `./viewport` does not exist.
+
+- [ ] **Step 3: Implement `web/src/lib/viewport.ts`**
+
+```ts
+/**
+ * The slice of VisualViewport this needs, as a seam for tests — and with the
+ * handlers as *properties*, not addEventListener: the event's name is also a
+ * Tailwind utility name, and a quoted string of it in any scanned source
+ * compiles a stray rule (see the scanner notes in src/styles.css).
+ */
+export interface ViewportLike {
+ readonly height: number
+ readonly offsetTop: number
+ readonly scale: number
+ onresize: ((ev: Event) => void) | null
+ onscroll: ((ev: Event) => void) | null
+}
+
+/**
+ * Keep the pane inside the *visual* viewport.
+ *
+ * The pane fills the layout viewport, but a phone's keyboard shrinks only the
+ * visual one — the page cannot scroll under the terminal, so without this the
+ * bottom rows (and the prompt) sit behind the keyboard. Pinning the pane's
+ * height to the visual viewport lets the ResizeObserver in terminal.tsx do
+ * the rest: the inner box shrinks, the fit is re-reported, the pty rows land
+ * above the keyboard.
+ *
+ * While pinch-zoomed (scale > 1) the rules invert. A zoom is not a layout
+ * change, so the pane is left alone — refitting the pty on a pinch would
+ * reflow the very text being magnified — and the surface's touch-action is
+ * released so a single finger pans the zoomed page, which the stylesheet
+ * otherwise reserves for the scrollback drag handler.
+ */
+export function trackVisualViewport(opts: {
+ pane: HTMLElement
+ surface: HTMLElement
+ viewport: ViewportLike | null
+}): () => void {
+ const { pane, surface, viewport } = opts
+ if (!viewport) return () => {}
+
+ const apply = () => {
+ if (viewport.scale > 1.01) {
+ surface.style.touchAction = 'auto'
+ return
+ }
+ surface.style.touchAction = ''
+ pane.style.height = `${viewport.height}px`
+ pane.style.transform = `translateY(${viewport.offsetTop}px)`
+ }
+
+ viewport.onresize = apply
+ viewport.onscroll = apply
+ apply()
+
+ return () => {
+ viewport.onresize = null
+ viewport.onscroll = null
+ surface.style.touchAction = ''
+ pane.style.height = ''
+ pane.style.transform = ''
+ }
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd web && npx vitest run src/lib/viewport.test.ts`
+Expected: PASS (5 tests).
+
+Note the dispose test asserts `pane.getAttribute('style')` is `''` — jsdom leaves an empty `style` attribute after properties are cleared. If it reports `null` instead, relax that one assertion to `expect(pane.style.height).toBe('')` plus `expect(pane.style.transform).toBe('')`; the behavior under test is "nothing remains set", not the attribute's presence.
+
+- [ ] **Step 5: Wire into the terminal effect**
+
+In `web/src/components/terminal.tsx`:
+
+Add to the imports from `@/lib`:
+
+```ts
+import { trackVisualViewport } from '@/lib/viewport'
+```
+
+Inside the main effect, directly after the four `surface.addEventListener('touch…')` registrations:
+
+```ts
+ // The pane hugs the visual viewport: a phone keyboard shrinks it and the
+ // ResizeObserver below refits the terminal above the keyboard. While
+ // pinch-zoomed it instead releases the surface so one finger pans.
+ const untrackViewport = trackVisualViewport({
+ pane,
+ surface,
+ viewport: window.visualViewport,
+ })
+```
+
+In the cleanup, next to the touch listener removals:
+
+```ts
+ untrackViewport()
+```
+
+- [ ] **Step 6: Ask Android's keyboard to resize the layout too**
+
+In `web/index.html`, extend the viewport meta:
+
+```html
+
+```
+
+(`interactive-widget=resizes-content` makes Chrome on Android shrink the layout viewport under the keyboard; iOS ignores it and is covered by the visualViewport tracker. Both paths converge on the same ResizeObserver.)
+
+- [ ] **Step 7: Run the web suite and the build guard**
+
+Run: `cd web && npx vitest run`
+Expected: PASS. `styles.build.test.ts` matters most here — the comments added to `viewport.ts` and `terminal.tsx` are inside Tailwind's scan perimeter, and this proves no bare utility rule leaked from their prose.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add web/src/lib/viewport.ts web/src/lib/viewport.test.ts web/src/components/terminal.tsx web/index.html
+git commit -m "fix(web): keep the prompt above the phone keyboard
+
+The pane filled the layout viewport, but a phone keyboard shrinks only
+the visual one and the page under the terminal cannot scroll — so the
+prompt sat behind the keys. The pane now pins itself to the visual
+viewport and the existing ResizeObserver refits the pty above the
+keyboard; Android gets the same through interactive-widget. While
+pinch-zoomed the tracker instead releases touch-action so one finger
+pans the magnified page."
+```
+
+---
+
+### Task 3: Pty follows the most recently active view
+
+The daemon keeps the pty at the componentwise maximum across attached views (`effectiveLocked`, `internal/daemon/server.go`), so a phone beside an open laptop tab always renders scaled down. Chosen replacement (tmux `window-size latest` semantics): the pty wears the fitted size of the **most recently active** view. The activity order already exists — `touch()` moves a conn to the back of `s.attached[id]` on input, resize and signal, and attach appends — so the policy change is: `effectiveLocked` walks that list from the back and returns the first recorded desire, and the *input* path also re-syncs the pty, because typing is how a view takes the size back without re-reporting.
+
+The web client needs no behavioral change: a view whose fit is at or above the pty already renders one-to-one (letterboxed in its pane), a smaller view already scales down.
+
+**Files:**
+- Modify: `internal/daemon/server.go` (`effectiveLocked`, `recordDesire`, new `effective`)
+- Modify: `internal/daemon/conn.go` (input path, `wire.Resize` handler, new `syncSize`)
+- Modify: `internal/daemon/server_test.go` (replace `TestResizeKeepsTheLargestAttachedView`)
+- Modify: `spec/protocol.md` (Sizing section)
+- Modify: `web/src/components/terminal.tsx` (the "## The sizing policy" doc comment only)
+
+**Interfaces:**
+- Consumes: nothing from Tasks 1–2.
+- Produces: `(s *Server) effective(id string) (viewSize, bool)` — the most recently active view's desire, behind `primaryMu`. `(c *conn) syncSize(a *attachment)` — resizes the pty to `effective` and broadcasts when it differs from the session's current size. `recordDesire(id string, c *conn, cols, rows uint16)` loses its return values.
+
+- [ ] **Step 1: Write the failing test**
+
+In `internal/daemon/server_test.go`, replace `TestResizeKeepsTheLargestAttachedView` (keep its position in the file) with:
+
+```go
+// TestPtySizeFollowsTheActiveView pins the sizing policy: the PTY wears the
+// fitted size of the most recently active view — attaching, reporting a size
+// and typing all count as activity. A phone that attaches beside a laptop
+// gets a phone-sized terminal the moment it reports, and the laptop takes
+// the size back with its first keystroke, no re-report needed.
+func TestPtySizeFollowsTheActiveView(t *testing.T) {
+ ts, reg := newTestServer(t)
+ s, err := reg.Spawn(session.SpawnOpts{Cmd: []string{"sleep", "2"}, Cols: 80, Rows: 24})
+ if err != nil {
+ t.Fatalf("Spawn: %v", err)
+ }
+ defer s.Close()
+
+ laptop := dial(t, ts)
+ writeControl(t, laptop, wire.Hello{Ver: "test"})
+ writeControl(t, laptop, wire.Attach{ID: s.ID(), LastSeq: 0})
+ var laptopRef uint32
+ readUntil(t, laptop, func(msg any, _ []byte) bool {
+ a, ok := msg.(wire.Attached)
+ if ok {
+ laptopRef = a.Ref
+ }
+ return ok
+ })
+ writeControl(t, laptop, wire.Resize{Ref: laptopRef, Cols: 120, Rows: 40, Primary: true})
+ waitFor(t, func() bool { return s.Info().Cols == 120 && s.Info().Rows == 40 })
+
+ phone := dial(t, ts)
+ writeControl(t, phone, wire.Hello{Ver: "test"})
+ writeControl(t, phone, wire.Attach{ID: s.ID(), LastSeq: 0})
+ var phoneRef uint32
+ readUntil(t, phone, func(msg any, _ []byte) bool {
+ a, ok := msg.(wire.Attached)
+ if ok {
+ phoneRef = a.Ref
+ }
+ return ok
+ })
+
+ // The phone just attached and reported, which makes it the active view:
+ // the PTY reshapes to the phone rather than staying with the largest.
+ writeControl(t, phone, wire.Resize{Ref: phoneRef, Cols: 40, Rows: 10, Primary: false})
+ waitFor(t, func() bool { return s.Info().Cols == 40 && s.Info().Rows == 10 })
+
+ // A keystroke on the laptop moves the activity back; its recorded desire
+ // is applied without the laptop re-reporting anything.
+ frame := wire.EncodeBinary(wire.FrameInput, laptopRef, []byte("k"))
+ if err := laptop.Write(context.Background(), websocket.MessageBinary, frame); err != nil {
+ t.Fatalf("write input: %v", err)
+ }
+ waitFor(t, func() bool { return s.Info().Cols == 120 && s.Info().Rows == 40 })
+
+ // The phone's keyboard opening is a fresh report, and a report is
+ // activity: the size follows the phone again.
+ writeControl(t, phone, wire.Resize{Ref: phoneRef, Cols: 40, Rows: 15, Primary: false})
+ waitFor(t, func() bool { return s.Info().Cols == 40 && s.Info().Rows == 15 })
+
+ // The phone leaves; the laptop is what remains, and its desire returns
+ // without anyone asking again.
+ writeControl(t, phone, wire.Detach{Ref: phoneRef})
+ waitFor(t, func() bool { return s.Info().Cols == 120 && s.Info().Rows == 40 })
+}
+```
+
+(`context`, `websocket`, `wire` and `session` are already imported by this file; `waitFor` already exists below the old test — keep it.)
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/daemon/ -run TestPtySizeFollowsTheActiveView`
+Expected: FAIL at the second `waitFor` — under the max policy the phone's 40x10 report leaves the pty at 120x40.
+
+- [ ] **Step 3: Implement the policy in server.go**
+
+Replace `recordDesire` and `effectiveLocked` in `internal/daemon/server.go`:
+
+```go
+// recordDesire notes c's fitted size for a session. What the PTY is actually
+// set to is effective's business: the desire of the most recently active
+// view, which after a report is usually — but not necessarily — the reporter.
+func (s *Server) recordDesire(id string, c *conn, cols, rows uint16) {
+ s.primaryMu.Lock()
+ defer s.primaryMu.Unlock()
+ m := s.desired[id]
+ if m == nil {
+ m = map[*conn]viewSize{}
+ s.desired[id] = m
+ }
+ m[c] = viewSize{cols: cols, rows: rows}
+}
+
+// effective is effectiveLocked behind its lock, for callers outside the
+// primaryMu critical sections.
+func (s *Server) effective(id string) (viewSize, bool) {
+ s.primaryMu.Lock()
+ defer s.primaryMu.Unlock()
+ return s.effectiveLocked(id)
+}
+
+// effectiveLocked computes, under primaryMu, the size the PTY should wear:
+// the fitted size of the most recently active view that has reported one.
+// The attachment list already encodes recency — touch keeps each session's
+// most recent client at the back — so the walk is from the back, skipping
+// views that have yet to report. One pty has one grid, so someone must be
+// chosen; choosing the view being *used* is what lets a phone pick a session
+// up at phone size and a laptop take it back with a keystroke.
+func (s *Server) effectiveLocked(id string) (viewSize, bool) {
+ list := s.attached[id]
+ desires := s.desired[id]
+ for i := len(list) - 1; i >= 0; i-- {
+ if v, ok := desires[list[i]]; ok {
+ return v, true
+ }
+ }
+ return viewSize{}, false
+}
+```
+
+(`releasePrimary` already calls `effectiveLocked` and its callers already resize to the result, so detach hand-back needs no change.)
+
+- [ ] **Step 4: Sync the pty on activity in conn.go**
+
+Add to `internal/daemon/conn.go`:
+
+```go
+// syncSize points the PTY at the effective size — the most recently active
+// view's desire — and broadcasts when that is a change. Called wherever
+// activity may have moved which view is effective: after a size report, and
+// after every input frame, because typing is how a view takes the size back
+// without re-reporting it.
+func (c *conn) syncSize(a *attachment) {
+ eff, ok := c.srv.effective(a.s.ID())
+ if !ok {
+ return
+ }
+ info := a.s.Info()
+ if eff.cols == info.Cols && eff.rows == info.Rows {
+ return
+ }
+ if err := a.s.Resize(eff.cols, eff.rows); err != nil {
+ c.sendError("resize_failed", err.Error())
+ return
+ }
+ c.srv.broadcastSize(a.s.ID(), eff.cols, eff.rows)
+}
+```
+
+In `handleBinary`, after the existing `c.srv.touch(a.s.ID(), c)`:
+
+```go
+ c.srv.touch(a.s.ID(), c)
+ c.syncSize(a)
+ if err := a.s.Write(payload); err != nil {
+```
+
+In `handleControl`'s `case wire.Resize:`, replace everything from the policy comment through the `broadcastSize` call with:
+
+```go
+ c.srv.touch(a.s.ID(), c)
+ if m.Primary {
+ c.srv.setPrimary(a.s.ID(), c)
+ }
+ // The report is recorded for every view, but the PTY follows the most
+ // recently active one — which this reporter, having just been touched,
+ // now is. An idle view's desire therefore waits, and is applied the
+ // moment that view speaks again (see syncSize on the input path). The
+ // primary role above still decides who answers device queries; it
+ // does not own the dimensions.
+ c.srv.recordDesire(a.s.ID(), c, m.Cols, m.Rows)
+ c.syncSize(a)
+```
+
+- [ ] **Step 5: Run the daemon tests**
+
+Run: `go test ./internal/daemon/`
+Expected: PASS, including `TestPtySizeFollowsTheActiveView`, `TestPrimarySeizureResizesPTY` (single view: its desire is the effective one) and the promotion tests (primary logic untouched).
+
+- [ ] **Step 6: Rewrite the policy prose**
+
+`spec/protocol.md`, replace the first paragraph of the `### Sizing` section with:
+
+```markdown
+Every attached view sends `resize` with the cells that fit its own pane. The
+daemon records one desired size per attachment and keeps the PTY at the fit
+of the **most recently active** view — activity being an input frame, a size
+report, a signal, or the attach itself. It recomputes when a report lands,
+when activity moves between views, and when an attachment ends, broadcasting
+the result as `sizeChanged`. A view whose fit is below the broadcast size
+renders the full screen scaled down; one whose fit is at or above it renders
+the grid one-to-one. One pty has one grid, so someone must be chosen, and it
+is the view being used: picking up the phone reshapes the session to the
+phone as soon as it reports, and the laptop's next keystroke reshapes it
+back — an idle view's report is never lost, only waiting.
+```
+
+`web/src/components/terminal.tsx`, replace the "## The sizing policy" paragraph of the component doc comment (keep the surrounding sections) with:
+
+```
+ * ## The sizing policy
+ *
+ * Every attached view measures its own pane and reports the cells that fit
+ * it; the daemon sizes the pty to the fit of the most recently active view
+ * (`effectiveLocked` in internal/daemon) — activity being input, a report,
+ * a signal or the attach itself. A view whose own fit matches or exceeds
+ * the pty renders it one-to-one; a smaller view renders the full screen and
+ * scales the whole surface down, staying fully interactive. Picking up the
+ * phone therefore reshapes the session to the phone the moment its report
+ * lands, and the first keystroke back on the laptop reshapes it back; a
+ * detaching view hands the size to whichever remaining view was active
+ * last. Ownership of the *primary* role never moves with any of this — the
+ * daemon keeps one client primary purely to answer device queries.
+```
+
+- [ ] **Step 7: Verify the whole tree**
+
+Run: `make test`
+Expected: PASS across Go, web and relay. `styles.build.test.ts` re-proves the edited `terminal.tsx` comment leaked no utility rule.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add internal/daemon/server.go internal/daemon/conn.go internal/daemon/server_test.go spec/protocol.md web/src/components/terminal.tsx
+git commit -m "feat(daemon): pty follows the most recently active view
+
+The componentwise-max policy meant a phone beside an open laptop tab
+always rendered the laptop's grid scaled down to unreadable. The pty now
+wears the fit of the most recently active view — attach, resize report,
+signal and input all count — so the phone gets a phone-sized terminal
+the moment it speaks and the laptop takes the size back with its first
+keystroke, its recorded desire applied without a re-report. tmux calls
+this window-size latest. Detach hand-back is unchanged: the size goes to
+whichever remaining view was active last."
+```
+
+---
+
+## Self-Review Notes
+
+- Spec coverage: pinch-zoom (Task 1), keyboard/visual viewport including Android meta (Task 2), sizing policy per the user's "follow active device" choice (Task 3). The "AI agent disabled scrolling stuff" concern is Task 1's `touch-action` plus Task 2's zoomed-pan release; touch-drag scrollback is deliberately preserved.
+- Type consistency: `ViewportLike` shape matches between Task 2's test and implementation; `effective`/`syncSize`/`recordDesire` signatures consistent across Task 3 steps.
+- Known risk, called out in Task 2 Step 4: jsdom's `style` attribute serialization after clearing properties; the step says exactly how to relax the assertion without weakening the behavior under test.
+- Deliberate semantics, decided with the user: a size report *is* activity (an idle laptop whose browser window is being resized has a human at it; a phone whose keyboard opens should win the pty immediately). The input-path `syncSize` covers the one case reports cannot: switching devices without any layout change on the destination.