diff --git a/docs/mobile-audit.md b/docs/mobile-audit.md new file mode 100644 index 0000000..24ac3c9 --- /dev/null +++ b/docs/mobile-audit.md @@ -0,0 +1,116 @@ +# Mobile audit — TeamWork + +**Written 2026-07-28** after a run of mobile bug reports that were all symptoms +of the same thing: TeamWork was designed at desktop width and made to *fit* a +phone, rather than designed for one. + +The bugs are worth fixing and mostly are. This document is about the layer under +them — what a phone is actually good for, and what the app currently offers +instead. + +> **Verification status: none of this is tested on a real device.** There is no +> touch hardware on the build machine. Every layout fix shipped so far is +> correct-by-construction and several were wrong in ways only a phone revealed — +> including one where suppressing a scroll gesture to fix chat broke Kanban +> scrolling entirely. Treat the recommendations as reasoned, not validated. + +--- + +## 1. The navigation spends its scarcest resource badly + +Five bottom-bar slots is all a phone gets. Today: + +| Slot | Surface | Honest assessment on a phone | +|---|---|---| +| 1 | Spaces | Right. The overview you land on. | +| 2 | Chat | Right. The primary thing you do. | +| 3 | **Browser** | **Wrong.** A CDP screencast of a 1920×1080 desktop Chrome, scaled onto 390px. You can see that something is happening; you cannot read it or use it. | +| 4 | **Scheduler** | **Wrong.** Low-frequency admin — you set a schedule once and rarely look again. Also duplicated inside More. | +| 5 | More | Right, as a spillover. | + +Meanwhile **Tasks (the Kanban board)** and **Library (notes)** are buried in +More — and those are the two most thumb-native surfaces in the product. Checking +a board and moving a card is exactly what phones are for. Reading a note is +exactly what phones are for. + +**Recommendation — reorder to: Chat · Spaces · Tasks · Library · More.** +Browser, Terminal, Desktop, Traces, Observability, Progress, Scheduler and +Settings all live in More, where a surface you visit occasionally belongs. + +## 2. Three panels are desktop-only in substance, not just in layout + +Terminal, Desktop (noVNC) and Browser are "watch the agent work" surfaces built +around a large viewport. On a phone they are, at best, read-only reassurance. + +That is not automatically wrong — glancing at what the agent is doing has real +value, and the terminal is genuinely usable in short bursts. But they should be +**framed** as glance surfaces rather than presented as equals to Chat. + +**The browser is fixable, and worth fixing.** The sandbox drives Chrome over +CDP, so `Emulation.setDeviceMetricsOverride` can render the page at phone +dimensions instead of scaling a desktop screencast down. The agent would then +browse *as a phone*, which is both more readable on mobile AND more +representative of what most of the web now serves. That is a prax-sandbox + +BrowserPanel change, not a CSS one. + +**The desktop (noVNC) is not fixable** in the same way — a Linux desktop is a +Linux desktop. Keep it in More and expect it to be used rarely from a phone. + +## 3. The recurring layout traps (now guarded) + +Every mobile bug so far has been one of a small number of patterns. Two are now +enforced by tests that fail the build: + +| Pattern | Symptom | Status | +|---|---|---| +| `flex-1 flex-col overflow-hidden` without `min-h-0` | content clipped, unscrollable | **guarded** — 7 panels fixed | +| inline pixel width | column wider than the screen, dead band beside it | **guarded** | +| fixed-width sidebar in a row | content squeezed to a sliver | fixed in 3 panels, ungarded | +| popover with a fixed width | pushes the page wide | fixed in 5, ungarded | +| `vh` where `svh` is meant | pane pushed past the bottom edge on iOS | fixed in 1, **ungarded** | +| `window.resize` for keyboard changes | pane keeps a height that no longer fits | fixed in 2, **ungarded** | + +The unguarded rows are the next thing to encode. A guard is worth more than a +fix, because the fix is one panel and the guard is every future panel. + +## 4. The keyboard is a first-class layout state, and was treated as an edge case + +The mobile tab bar is `fixed bottom-0`, so it anchors to the bottom of the +visible area and sits over the composer while you type. Two attempts: + +1. **Viewport arithmetic** — `window.innerHeight - visualViewport.height > 150`. + Failed on iOS, where the layout viewport can shrink too, leaving the + difference near zero and the rule silently never applying. +2. **Focus** — a `focusin`/`focusout` listener on text fields. Deterministic, + no threshold to tune, works everywhere. + +The lesson generalises: **ask the question directly rather than inferring it +from a measurement.** The same mistake produced the terminal bug — panels +listened to `window.resize`, which never fires for a keyboard, instead of +`visualViewport`. + +## 5. What a phone-first version would look like + +Not a proposal to build today, but the shape worth aiming at: + +- **Chat is the app.** Everything else is somewhere you go and come back from. +- **The board is one tap away**, because "what is the agent doing / what is + left" is the most common mobile question. +- **Agent-work surfaces are glances** — a screenshot-and-status view of the + browser/desktop rather than a live interactive canvas, with the live version + reserved for desktop. +- **Long text is read, not authored.** Note *reading* is mobile-native; note + editing mostly is not, and the editor should degrade gracefully rather than + pretend. +- **Nothing is `fixed` except the tab bar**, and the tab bar knows about the + keyboard. + +## Priorities + +1. **Reorder the tab bar** — cheap, high impact, no risk. *(Doing now.)* +2. **Guard the remaining layout patterns** — `svh`, `visualViewport` listeners. +3. **Mobile-emulated browser** via CDP device metrics — turns a dead tab into a + useful one. +4. **Get a real device into the loop.** Everything above is reasoning. The three + bugs that reached the user today were all found by looking at a phone, and + none would have been caught by any test written here. diff --git a/frontend/e2e/mobile.spec.ts b/frontend/e2e/mobile.spec.ts new file mode 100644 index 0000000..a79c92e --- /dev/null +++ b/frontend/e2e/mobile.spec.ts @@ -0,0 +1,140 @@ +/** + * Mobile layout checks at a real phone viewport. + * + * Every mobile bug reported so far was found by a human looking at a phone, and + * none would have been caught by a jsdom test — jsdom has no layout engine, so + * it cannot tell you something is off-screen, clipped, or wider than the window. + * Playwright can: it lays out for real, at a real viewport, with touch enabled. + * + * These assertions are the bugs that actually happened, written down: + * + * - a desktop column width leaked to mobile and left a dead band beside the + * content (inline pixel width outranking `w-full`); + * - the trace summary was crushed to a one-line sliver by a sibling that + * refused to shrink, with no way to scroll to it; + * - long unbreakable URLs made the message list wider than the screen, so + * vertical scrolls drifted sideways. + * + * Run against a live instance: + * BASE_URL=https://teamwork.your-tailnet.ts.net npx playwright test e2e/mobile.spec.ts + * + * NOT covered here, and worth being explicit about: the on-screen KEYBOARD. + * Playwright cannot summon one, so `visualViewport` never shrinks and the + * composer-hidden-by-the-tab-bar class of bug stays device-only. Focus events + * fire, so the class toggle is checkable; the geometry it exists to fix is not. + */ +import { devices, expect, test, type Page } from '@playwright/test'; + +const BASE = process.env.BASE_URL || 'http://localhost:3000'; + +test.use({ ...devices['iPhone 13'], baseURL: BASE }); + +/** Widest element that overflows the viewport, if any — for a useful failure. */ +async function horizontalOverflow(page: Page) { + return page.evaluate(() => { + const vw = document.documentElement.clientWidth; + const guilty: { tag: string; cls: string; width: number }[] = []; + document.querySelectorAll('*').forEach((el) => { + const r = el.getBoundingClientRect(); + if (r.width > vw + 1 && r.width > 0) { + guilty.push({ + tag: el.tagName.toLowerCase(), + cls: (el.className || '').toString().slice(0, 80), + width: Math.round(r.width), + }); + } + }); + return { viewport: vw, scrollWidth: document.documentElement.scrollWidth, guilty: guilty.slice(0, 5) }; + }); +} + +/** Open the first project's workspace. + * + * The card is a clickable div, not a link — my first version looked for an + * `href` and silently stayed on the projects page, so four "passing" checks + * were measuring a page with almost nothing on it. The screenshot said so + * immediately, which is the argument for taking them. + */ +async function openWorkspace(page: Page) { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + await expect(page.locator('text=My Projects').first()).toBeVisible({ timeout: 15_000 }); + const card = page.locator('div[class*="cursor-pointer"]').first(); + await expect(card).toBeVisible({ timeout: 5_000 }); + await card.click(); + await page.waitForLoadState('networkidle'); + // The tab bar only exists inside a workspace; wait for it rather than a + // fixed timeout, so a slow load fails as a timeout and not as a wrong answer. + await expect(page.locator('.mobile-tab-bar')).toBeVisible({ timeout: 15_000 }); +} + +test.describe('mobile layout', () => { + test('nothing is wider than the screen', async ({ page }) => { + await openWorkspace(page); + const result = await horizontalOverflow(page); + expect( + result.guilty, + `elements wider than the ${result.viewport}px viewport — a desktop width ` + + 'has leaked to mobile, or unbreakable text is not wrapping', + ).toEqual([]); + }); + + test('the page itself does not scroll sideways', async ({ page }) => { + await openWorkspace(page); + const { scrollWidth, viewport } = await horizontalOverflow(page); + expect(scrollWidth, 'the document scrolls horizontally').toBeLessThanOrEqual(viewport + 1); + }); + + test('the bottom tab bar is present and reachable', async ({ page }) => { + await openWorkspace(page); + const bar = page.locator('.mobile-tab-bar'); + await expect(bar).toBeVisible(); + + // Touch targets: anything under ~40px is a miss waiting to happen. + const buttons = bar.locator('button'); + const n = await buttons.count(); + expect(n, 'the tab bar should have five slots').toBeGreaterThanOrEqual(4); + for (let i = 0; i < n; i++) { + const box = await buttons.nth(i).boundingBox(); + expect(box!.height, `tab ${i} is only ${box!.height}px tall`).toBeGreaterThanOrEqual(40); + } + }); + + test('a long unbreakable URL does not widen the layout', async ({ page }) => { + await openWorkspace(page); + // Inject the exact shape that caused the sideways drift. + await page.evaluate(() => { + const host = document.querySelector('.message-text') || document.body; + const p = document.createElement('p'); + p.className = 'message-text'; + p.textContent = 'https://x.com/someone/status/2081762065392541951?s=46&extra=' + 'a'.repeat(120); + host.appendChild(p); + }); + const result = await horizontalOverflow(page); + expect(result.guilty, 'a long URL made something wider than the screen').toEqual([]); + }); + + test('every scrollable pane can actually scroll to its end', async ({ page }) => { + await openWorkspace(page); + const clipped = await page.evaluate(() => { + const bad: { cls: string; scrollH: number; clientH: number }[] = []; + document.querySelectorAll('.overflow-hidden').forEach((el) => { + const e = el as HTMLElement; + // Content taller than the box, with no scroller of its own and none + // among its children, is content nobody can reach. + if (e.scrollHeight > e.clientHeight + 4) { + const hasScroller = e.querySelector('.overflow-y-auto, .overflow-auto'); + if (!hasScroller) { + bad.push({ + cls: e.className.toString().slice(0, 90), + scrollH: e.scrollHeight, + clientH: e.clientHeight, + }); + } + } + }); + return bad.slice(0, 5); + }); + expect(clipped, 'content is clipped with no way to scroll to it').toEqual([]); + }); +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 19f9942..06a1ce8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -52,16 +52,11 @@ function App() { root.setProperty('--app-height', `${vv.height}px`); root.setProperty('--app-top', `${vv.offsetTop}px`); - // Is the on-screen keyboard up? The visual viewport shrinks while the - // layout viewport does not, so the gap between them is the keyboard. - // 150px is comfortably more than the browser chrome that also comes and - // goes on scroll, and comfortably less than any real keyboard. - // - // This matters because the mobile tab bar is `fixed bottom-0`, which - // pins it to the LAYOUT bottom — so when the keyboard opens it sits - // squarely over the composer and you cannot see what you are typing. - const keyboardOpen = window.innerHeight - vv.height > 150; - document.documentElement.classList.toggle('keyboard-open', keyboardOpen); + // NOTE: keyboard detection is deliberately NOT done here. The obvious + // heuristic — `window.innerHeight - vv.height > 150` — was tried and does + // not work on iOS, where the layout viewport can shrink along with the + // visual one, leaving a difference near zero and the check silently + // false. See the focus listener below, which asks the question directly. }; update(); vv.addEventListener('resize', update); @@ -72,6 +67,41 @@ function App() { }; }, []); + // Is a text field focused? That is the question worth asking — not "how tall + // is the viewport", which was the first attempt and failed on the platform it + // was written for. + // + // The mobile tab bar is `fixed bottom-0`, so it anchors to the bottom of the + // visible area and sits over the message composer while you type. Focus is + // deterministic: the field either has it or it does not, on every browser, + // with no threshold to tune and nothing to be wrong about. + useEffect(() => { + const isTextField = (el: EventTarget | null) => { + const node = el as HTMLElement | null; + if (!node || !node.tagName) return false; + const tag = node.tagName.toLowerCase(); + return tag === 'input' || tag === 'textarea' || node.isContentEditable; + }; + const onFocus = (e: FocusEvent) => { + if (isTextField(e.target)) { + document.documentElement.classList.add('keyboard-open'); + } + }; + const onBlur = (e: FocusEvent) => { + // relatedTarget is where focus is GOING. Moving between two fields should + // not flash the bar back for one frame. + if (isTextField(e.target) && !isTextField(e.relatedTarget)) { + document.documentElement.classList.remove('keyboard-open'); + } + }; + document.addEventListener('focusin', onFocus); + document.addEventListener('focusout', onBlur); + return () => { + document.removeEventListener('focusin', onFocus); + document.removeEventListener('focusout', onBlur); + }; + }, []); + return ( {/* Last line of defence. Panels have their own boundaries; this one keeps diff --git a/frontend/src/__tests__/mobile-layout.test.ts b/frontend/src/__tests__/mobile-layout.test.ts index 4670bf5..3c11795 100644 --- a/frontend/src/__tests__/mobile-layout.test.ts +++ b/frontend/src/__tests__/mobile-layout.test.ts @@ -69,3 +69,40 @@ describe('mobile layout', () => { ).toEqual([]); }); }); + + it('uses svh, not vh, for mobile pane heights', () => { + // On iOS `vh` is the LARGE viewport — as if browser chrome were hidden — + // so an h-[40vh] pane is taller than 40% of what is actually visible and + // can be pushed past the bottom edge. `svh` is the small viewport, the one + // that is always really there. Desktop-only values (md:h-…) are exempt: + // desktop browsers do not have the dual-viewport problem. + const offenders: string[] = []; + for (const file of tsxFiles(SRC)) { + readFileSync(file, 'utf8').split('\n').forEach((line, i) => { + const mobileVh = /(? { + // window.resize never fires when a mobile keyboard opens — only the visual + // viewport shrinks. A pane that refits on window.resize alone keeps a + // height that no longer fits and its lower half sits behind the keyboard. + // This is how the terminal lost its bottom half. + const offenders: string[] = []; + for (const file of tsxFiles(SRC)) { + const text = readFileSync(file, 'utf8'); + const listens = text.includes("addEventListener('resize'") + && text.includes('window.addEventListener'); + if (listens && !text.includes('visualViewport')) { + offenders.push(file.replace(SRC, '')); + } + } + expect(offenders, 'a resize listener without a visualViewport listener ' + + 'will not see the keyboard').toEqual([]); + }); diff --git a/frontend/src/components/panels/ClaudePanel.tsx b/frontend/src/components/panels/ClaudePanel.tsx index 2a9f65b..e397ccd 100644 --- a/frontend/src/components/panels/ClaudePanel.tsx +++ b/frontend/src/components/panels/ClaudePanel.tsx @@ -171,9 +171,17 @@ function EmbeddedTerminal({ } }; window.addEventListener('resize', handleResize); + // Same as TerminalPanel: a mobile keyboard shrinks the VISUAL viewport + // only, so `window.resize` never fires and the pane keeps a height that no + // longer fits. + const vv = window.visualViewport; + vv?.addEventListener('resize', handleResize); + vv?.addEventListener('scroll', handleResize); return () => { window.removeEventListener('resize', handleResize); + vv?.removeEventListener('resize', handleResize); + vv?.removeEventListener('scroll', handleResize); ws.close(); term.dispose(); }; diff --git a/frontend/src/components/panels/HomeDashboard.tsx b/frontend/src/components/panels/HomeDashboard.tsx index a3f0122..988d45a 100644 --- a/frontend/src/components/panels/HomeDashboard.tsx +++ b/frontend/src/components/panels/HomeDashboard.tsx @@ -16,6 +16,7 @@ import { clsx } from 'clsx'; import { Star, Target, Calendar, Archive as ArchiveIcon, CheckCircle2, Pause, X, Plus, Layout, Loader2, + Archive, } from 'lucide-react'; import { useLibrary, useCreateLibrarySpace } from '@/hooks/useApi'; import type { LibrarySpace } from '@/hooks/useApi'; @@ -53,27 +54,36 @@ export function HomeDashboard({ isVisible, onOpenProject }: Props) { return (
-
- -

Spaces

+ {/* Header sized for a phone first. At 390px, "New Space" wrapped onto + two lines and "Show archived" onto two more, so the header ate ~250px + of screen before any content — chrome outweighing the page. Labels + shrink to their essentials on mobile ("New", an icon-only archive + toggle) and expand at md. whitespace-nowrap because a wrapped button + label is how this was discovered in a screenshot. */} +
+ +

Spaces

diff --git a/frontend/src/components/panels/LibraryPanel.tsx b/frontend/src/components/panels/LibraryPanel.tsx index 7806aba..da5ce4f 100644 --- a/frontend/src/components/panels/LibraryPanel.tsx +++ b/frontend/src/components/panels/LibraryPanel.tsx @@ -1561,7 +1561,7 @@ export function LibraryPanel({ isVisible, onClose, onGoHome, focusProject, onFoc {/* Refine preview modal */} {refinePreview && ( setRefinePreview(null)} dark={dark}> -
+

Review the refinement

diff --git a/frontend/src/components/panels/SpacePage.tsx b/frontend/src/components/panels/SpacePage.tsx index 866a898..2d9df77 100644 --- a/frontend/src/components/panels/SpacePage.tsx +++ b/frontend/src/components/panels/SpacePage.tsx @@ -404,7 +404,7 @@ export function SpacePage({ spaceSlug, onBack }: Props) { value={editingNoteBody} onChange={(e) => setEditingNoteBody(e.target.value)} className={clsx( - 'min-h-[60vh] w-full resize-y rounded-xl border p-4 font-mono text-sm outline-none', + 'min-h-[60svh] w-full resize-y rounded-xl border p-4 font-mono text-sm outline-none', dark ? 'border-slate-700 bg-slate-950 text-slate-100' : 'border-gray-200 bg-white text-slate-900', )} /> @@ -1073,10 +1073,10 @@ function SpaceFiles({ spaceSlug, dark }: { spaceSlug: string; dark: boolean }) { // never cost you the composer.
{isImage && ( - {file.name} + {file.name} )} {isPdf && ( -