From c362e2af1c298a0936668894a86b92c5f826053d Mon Sep 17 00:00:00 2001 From: praxagent Date: Tue, 28 Jul 2026 01:30:09 +0000 Subject: [PATCH 1/3] fix(mobile): keyboard detection that works, terminal that refits, and a tab bar that earns its slots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THE KEYBOARD FIX DID NOT WORK — second attempt The first version compared window.innerHeight to visualViewport.height and assumed only the latter shrinks. On iOS both can, so the difference stayed near zero and the rule silently never applied — the tab bar went on covering the composer exactly as before. Now a focusin/focusout listener on text fields. Focus is a fact, not an estimate: the field either has it or it does not, on every browser, with no threshold to tune. The companion CSS rule was fragile in the same spirit — it targeted the generated Tailwind class by escaping every bracket of pb-[calc(3.5rem+env(safe-area-inset-bottom,0px))], a selector nobody can read and nothing verified. Replaced with a named pb-mobile-nav class. THE TAB BAR COVERED HALF THE TERMINAL Not the bar's fault: xterm listened to window.resize, which never fires when a mobile keyboard opens — the layout viewport is unchanged, only the visual one shrinks. So xterm kept its pre-keyboard height and its lower half sat behind the keyboard. Both TerminalPanel and ClaudePanel now listen to visualViewport resize AND scroll, since Safari also shifts the viewport when bringing a focused element into view without resizing anything. Same root error as the keyboard detection: measuring the wrong thing, and being confidently wrong because the measurement existed. THE TAB BAR SPENT ITS SLOTS BADLY Five slots is everything a phone gets. Browser and Scheduler held two of them. The browser tab is a CDP screencast of a 1920x1080 desktop Chrome scaled onto a 390px screen — you can see something is happening, you cannot read it. The scheduler is set-once admin and was already duplicated inside More. Meanwhile Tasks and Library — checking a board, moving a card, reading a note, the two most thumb-native things in the product — were buried under More. Now: Chat, Spaces, Tasks, Library, More. Browser moves INTO More, where it had never actually been listed, so removing it from the bar would have orphaned the panel. docs/mobile-audit.md records the whole audit: which panels are desktop-only in substance rather than layout, the recurring layout traps and which are now guarded by tests, why the keyboard is a first-class layout state, and what a phone-first version would look like. It also states plainly that none of it is verified on a device — every mobile bug that reached the user today was found by looking at a phone, and none would have been caught by any test in this repo. 39 frontend tests green, tsc clean. --- docs/mobile-audit.md | 116 ++++++++++++++++++ frontend/src/App.tsx | 50 ++++++-- .../src/components/panels/ClaudePanel.tsx | 8 ++ .../src/components/panels/TerminalPanel.tsx | 13 ++ frontend/src/index.css | 24 ++-- frontend/src/pages/ProjectWorkspace.tsx | 51 +++++--- 6 files changed, 225 insertions(+), 37 deletions(-) create mode 100644 docs/mobile-audit.md 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/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/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/TerminalPanel.tsx b/frontend/src/components/panels/TerminalPanel.tsx index 623fa4f..4e7bcf2 100644 --- a/frontend/src/components/panels/TerminalPanel.tsx +++ b/frontend/src/components/panels/TerminalPanel.tsx @@ -121,9 +121,22 @@ export function TerminalPanel({ projectId, isVisible, onClose }: TerminalPanelPr } }; window.addEventListener('resize', handleResize); + // The window does NOT fire `resize` when a mobile keyboard opens — the + // layout viewport is unchanged, only the VISUAL one shrinks. So xterm kept + // its pre-keyboard height and its lower half sat behind the keyboard, which + // reads as the tab bar covering half the terminal. Listening to the visual + // viewport is what actually observes that change. + // + // `scroll` as well as `resize`: Safari also shifts the visual viewport when + // it brings a focused element into view, without resizing anything. + 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(); wsRef.current = null; term.dispose(); diff --git a/frontend/src/index.css b/frontend/src/index.css index 6b5dc1f..a0bb300 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -53,23 +53,27 @@ button, [role="button"], a, summary, select { touch-action: manipulation; } -/* While the keyboard is up, the mobile tab bar gets out of the way. +/* While a text field is focused, the mobile tab bar gets out of the way. * - * It is `fixed bottom-0`, so it anchors to the LAYOUT viewport — which does not - * shrink when the keyboard opens. The bar therefore sat directly over the - * message composer, hiding the text as you typed it. Navigation you cannot use - * mid-sentence is worth less than seeing your own sentence. + * It is `fixed bottom-0`, so it anchors to the bottom of the visible area and + * sits directly over the message composer — you cannot see what you are typing. * - * It comes straight back when the field is dismissed. + * The class is set from a focusin/focusout listener rather than from viewport + * arithmetic. The first attempt compared window.innerHeight to + * visualViewport.height and assumed only the latter shrinks; on iOS both can, + * so the difference stayed near zero and the rule never applied. Focus is a + * fact, not an estimate. */ html.keyboard-open .mobile-tab-bar { display: none; } -/* Panes that reserved room for that bar should reclaim it at the same time, - * or the layout keeps a strip of dead space where the bar used to be. */ -html.keyboard-open .pb-\\[calc\\(3\\.5rem\\+env\\(safe-area-inset-bottom\\,0px\\)\\)\\] { - padding-bottom: 0; +/* Panes reserve room for that bar. They must give it back at the same moment, + * or a strip of dead space sits where the bar was. Targeted by a named class: + * the previous version escaped every bracket of the generated Tailwind class, + * which is unreadable and was never verified to match. */ +html.keyboard-open .pb-mobile-nav { + padding-bottom: 0 !important; } /* Long unbreakable strings must wrap. diff --git a/frontend/src/pages/ProjectWorkspace.tsx b/frontend/src/pages/ProjectWorkspace.tsx index e7df86a..94c82c9 100644 --- a/frontend/src/pages/ProjectWorkspace.tsx +++ b/frontend/src/pages/ProjectWorkspace.tsx @@ -503,7 +503,7 @@ export function ProjectWorkspace() { hidden without leaving the library view. */} {((activeView === 'files') || (activeView === 'library' && !libraryHideChat)) && projectId && (
-
+
{activeView === 'library' ? ( ) : ( @@ -526,7 +526,7 @@ export function ProjectWorkspace() { panels (Terminal/Browser/Desktop/etc.) don't render under the nav. Tailwind arbitrary value evaluates env() at runtime; the md:pb-0 override clears it on desktop where the nav isn't shown. */} -
+
{/* Desktop Panel — full Linux desktop via noVNC */} {activeView === 'desktop' && ( @@ -801,13 +801,19 @@ export function ProjectWorkspace() { 'md:hidden fixed bottom-0 left-0 right-0 z-30 flex items-center justify-around border-t', darkMode ? 'bg-slate-950 border-slate-800' : 'bg-white border-gray-200' )} style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}> - switchTo('spaces')} - darkMode={darkMode} - /> + {/* Five slots is everything a phone gets, so they go to what you can + actually DO with a thumb. + + Browser and Scheduler used to hold two of them. The browser tab is a + CDP screencast of a 1920x1080 desktop Chrome scaled onto a 390px + screen — you can see that something is happening, you cannot read + it. The scheduler is set-once admin, and was duplicated inside More + anyway. Meanwhile Tasks and Library — checking a board, moving a + card, reading a note, the two most thumb-native things in the + product — were buried under More. + + Both keep their place in More, where an occasional surface belongs. + See docs/mobile-audit.md. */} switchTo('browser')} + icon={Home} + label="Spaces" + active={activeView === 'spaces' || activeView === 'space'} + onClick={() => switchTo('spaces')} + darkMode={darkMode} + /> + switchTo('tasks')} darkMode={darkMode} /> switchTo('scheduler')} + icon={Library} + label="Library" + active={activeView === 'library'} + onClick={() => switchTo('library')} darkMode={darkMode} />
@@ -845,6 +858,10 @@ export function ProjectWorkspace() { darkMode ? 'bg-slate-800 border-slate-700' : 'bg-white border-gray-200' )}> { switchTo('tasks'); setMobileMoreOpen(false); }} darkMode={darkMode} /> + {/* Browser lives here now rather than in the tab bar — it was + never in this sheet, so removing it from the bar would have + orphaned the panel entirely. */} + { switchTo('browser'); setMobileMoreOpen(false); }} darkMode={darkMode} /> { switchTo('terminal'); setMobileMoreOpen(false); }} darkMode={darkMode} /> { switchTo('desktop'); setMobileMoreOpen(false); }} darkMode={darkMode} /> { switchTo('files'); setMobileMoreOpen(false); }} darkMode={darkMode} /> From bc85bfc13efe89c1d4ea4600e21a7380f2625ec9 Mon Sep 17 00:00:00 2001 From: praxagent Date: Tue, 28 Jul 2026 01:40:39 +0000 Subject: [PATCH 2/3] test(mobile): verify layout on a real phone viewport instead of asserting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every mobile bug so far was found by a human looking at a phone, and none would have been caught here — jsdom has no layout engine, so it cannot tell you something is off-screen, clipped, or wider than the window. It cannot fail the way these bugs failed. @playwright/test was already a dependency, with a config and an e2e spec in the repo. I wrote "not verified on a device" five times without reaching for it. The assertions are the bugs that actually happened: - nothing wider than the viewport, and the failure NAMES the offending elements rather than just asserting false; - the document does not scroll sideways; - the tab bar exists and every button clears 40px; - a long unbreakable URL injected into a message does not widen the layout — the exact shape that caused the drift; - nothing is clipped without a scroller, which is the trace-summary sliver generalised: content taller than its box, inside overflow-hidden, with no scroller anywhere beneath it. Five pass against the live instance at iPhone 13 dimensions. The first run is the argument for taking screenshots: four checks "passed" while sitting on the projects page, because my helper looked for an href and the project card is a clickable div. They were measuring an almost-empty page and reporting success. The screenshot showed it in one glance. NOT covered, stated in the spec header: the on-screen keyboard. Playwright cannot summon one, so visualViewport never shrinks and the composer-hidden-by-the-tab-bar geometry stays device-only. Focus events fire, so the class toggle is checkable; what it exists to fix is not. --- frontend/e2e/mobile.spec.ts | 140 ++++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 frontend/e2e/mobile.spec.ts 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([]); + }); +}); From ebb76fc9a316cda6c4f01ce00834edb4a7cf8f60 Mon Sep 17 00:00:00 2001 From: praxagent Date: Tue, 28 Jul 2026 02:01:59 +0000 Subject: [PATCH 3/3] fix(mobile): guard the last two layout traps, fix the ten panes they caught MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Working the mobile-audit punch list, item 2: encode the remaining traps as tests rather than trusting the fixes to stay fixed. TWO NEW GUARDS, AND WHAT THEY IMMEDIATELY FOUND - vh on a mobile pane must be svh. On iOS vh is the LARGE viewport (as if browser chrome were hidden), so an h-[85vh] modal is taller than the visible area and its bottom edge is unreachable. The guard found TEN offenders across six files — modals, previews, the workspace bottom sheet — every one a composer-under-the-keyboard bug waiting for the right screen. - Every window.resize listener must have a visualViewport companion, because window.resize never fires when a mobile keyboard opens. The guard found the modal Terminal variant — the SAME bug fixed in TerminalPanel hours earlier, alive in a sibling file. That is the argument for guards in one line: the fix is one panel, the guard is every panel including the ones not written yet. THE SPACES HEADER ATE A QUARTER OF THE SCREEN Found in my own Playwright screenshot rather than reported: at 390px, "New Space" wrapped to two lines and "Show archived" to two more, so the header consumed ~250px before any content. Labels now shrink to essentials on mobile ("New", an icon-only archive toggle with a title) and expand at md. whitespace-nowrap throughout, because a wrapped button label is exactly how this was discovered. 41 unit tests green, tsc clean, built. Playwright suite: 7 passing against the live instance at iPhone 13 dimensions. --- frontend/src/__tests__/mobile-layout.test.ts | 37 +++++++++++++++++++ .../src/components/panels/HomeDashboard.tsx | 24 ++++++++---- .../src/components/panels/LibraryPanel.tsx | 2 +- frontend/src/components/panels/SpacePage.tsx | 8 ++-- .../components/profiles/AgentLogViewer.tsx | 2 +- .../src/components/profiles/ProfileModal.tsx | 4 +- frontend/src/components/terminal/Terminal.tsx | 10 ++++- frontend/src/pages/ProjectWorkspace.tsx | 2 +- 8 files changed, 72 insertions(+), 17 deletions(-) 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/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 && ( -