
With v0.3.0, Foxl runs on your phone. Not a thin remote viewer and not a separate mobile codebase - the same web app that powers app.foxl.ai and the desktop shell, wrapped in a native iOS container with Capacitor. The Agent, Notes, and the new in-app Foxl Code all come along for free. This post is about the parts that were not free: docking the chat composer on the keyboard the way the Claude and ChatGPT apps do, getting the safe area right without breaking touch, and pointing one bundle at three different backends.
One bundle, three runtimes
Foxl's frontend is a single Vite build. It already ran in two places: the browser at app.foxl.ai, and inside Electron (which serves the same dist/ from a local Express server on a fixed loopback port). Capacitor adds a third host - a WKWebView that loads the bundle from capacitor://localhost. There is no second app to maintain, no React Native rewrite, and no iframe. The mobile project is just a native shell whose webDir points at the web build output.

The one thing that genuinely differs per host is how the in-app Foxl Code product reaches its orchestrator. The orchestrator Worker has no public hostname; it is reachable only through a Cloudflare Service Binding on the foxl-app Worker, which exposes it under a /forge-api prefix. At app.foxl.ai that call is same-origin. From Electron (127.0.0.1) and from Capacitor (capacitor://localhost) it is cross-origin, so the proxy answers a CORS preflight for those exact origins and forwards the rest. The bundle figures out which case it is in at runtime and builds the right base URL - so the network layer needs zero per-platform forks. (If you have ever seen /api 404s while developing against a local relay, that is the one environment with no proxy in front of it; every shipped runtime resolves to the deployed proxy and authenticates normally.)
The composer should dock on the keyboard
On a phone, the single most important interaction in a chat app is what happens when you tap the input. The bar should rise and sit flush on top of the keyboard, the conversation above should shrink to fit, the header should stay exactly where it is, and nothing should scroll. That is what the Claude and ChatGPT apps do, and it is deceptively hard to reproduce inside a WebView, because the OS keyboard and the web layout viewport do not naturally agree on anything.
Capacitor's keyboard plugin offers a few resize modes that each try to bridge that gap automatically - resizing the WebView frame, the document body, or the Ionic viewport. We tried the obvious one first, resize: 'native', which shrinks the WKWebView frame when the keyboard appears. It half-worked and looked wrong in two specific ways:
- Shrinking the WebView frame exposes the native window behind it for the height of the keyboard - a black band that flashes in before the keyboard image paints over it.
- The frame resize lands on its own schedule, slightly after the keyboard animation, with no transition to ride. The content snaps instead of sliding.
resize:none, then animate the gap ourselves
The fix was to stop letting the plugin move anything and do the layout in CSS. We set resize: 'none', which keeps the WKWebView at its full frame for the entire interaction. Because the WebView never shrinks, its white systemBackground always covers the whole screen - there is no window behind it to reveal, so the black band is gone by construction.

Under resize: 'none' the layout viewport does not change, so window.visualViewport never reports the keyboard - measuring it that way returns a zero gap. But the plugin still fires a keyboardWillShow event, and that event carries the one number we actually need: keyboardHeight. The whole mechanism hangs off it:
- On
keyboardWillShow, take keyboardHeight. - Subtract the bottom safe-area inset (the home-indicator strip, ~34px). The content is already inset by that much, so the layout only needs to give back the remainder - otherwise the composer floats a notch above the keyboard. We read the inset once from a hidden
env(safe-area-inset-bottom) probe. - Write the result to a single CSS variable,
--keyboard-gap, on the document root. - The shell column is sized
height: calc(100dvh - var(--keyboard-gap)) with a transition on height. Setting the variable shrinks the column from the bottom, GPU-animated and timed to the keyboard.
The layout does the rest for free. The shell is a flex column: a fixed header on top, a flex-1 scrollable message region in the middle, and the composer as the last child pinned to the bottom. When the column shrinks, the header stays pinned, the message region absorbs the loss, and the composer slides up to land exactly on the keyboard. On keyboardWillHide the gap goes back to zero and everything slides back down. We also disable the WebView's native scroll-assist and hide the input-accessory bar, so WebKit never tries to scroll the whole document to chase the focused field and we recover that vertical space.
The hook is a no-op anywhere that is not a native Capacitor build: it early-returns on web and desktop, the variable stays unset (and calc(100dvh - 0px) is just full height), so the exact same bundle behaves normally in the browser and in Electron.
The safe area: one inset source, no more
The harder bug was not visual - it was that taps started landing in the wrong place. The cause was having two things inset the layout at once. WKWebView with contentInset: 'always' already insets the web content past the Dynamic Island and the home indicator. That is the correct, single source of truth. We had also added viewport-fit=cover plus CSS env(safe-area-inset-*) padding on top of it - the usual web recipe for notches. Combined, the cover viewport expands the layout edge-to-edge while the scroll view stays inset, and the render coordinates drift out of sync with the touch hit-test coordinates. Visually fine; every tap off by the height of the inset.
The fix was to delete the web-side safe-area handling entirely and let the WebView be the only thing that insets content. One inset source, applied once, and the hit-testing lines back up. It is the kind of bug that is obvious in hindsight and invisible in a screenshot, which is why it is worth writing down: on iOS, pick exactly one layer to own the safe area.
What ships in v0.3.0
The mobile work in this release is the technical foundation: a Capacitor iOS host (ai.foxl.app) that wraps the full Foxl web experience - Agent chat, Notes, and the in-app Foxl Code - with native sign-in, the keyboard-docked composer, deep links, microphone access for Notes recording, and push notifications. Billing on mobile is read-only, with an external link to manage your plan on the web. The app is not in the App Store yet; v0.3.0 lays the groundwork that gets it there.
It also closes the loop on the larger v0.3.0 theme: Foxl Code folded into the same app shell as the Agent and Notes, so all three products now ride the one bundle onto the phone at once. Same account, same credits, same build - now in your pocket.