Skip to main content
ShellPort is the seam between framework-free UI logic and the native shell.

Quick Start

1

Read the insets synchronously

First paint places the composer above the home indicator, so insets are a synchronous snapshot, never a promise.
2

React to the keyboard

keyboardHeightPx is also a synchronous snapshot, so a warm resume with the keyboard already up lays out correctly on mount.
3

readKeyboardHeight is the single source of truth

One exported function computes the keyboard height, with the clamp and the zoom guard in one place.
4

createWebShell seeds the snapshot at construction

keyboardHeightPx is seeded, not declared = 0.

What The Shell Provides

These are the capabilities a phone needs but a desktop window does not.

The Two Adapters

Only adapters/src/tauri may import @tauri-apps/*, and inside it only bridge.ts touches them. tools/depgraph.mjs enforces this, so a React Native port is one directory rather than an audit.

How It Works

The seed at construction and the live handler read through the same function, so the clamp-at-0 and the pinch-zoom guard cannot drift between the first frame and every frame after it. The keyboardHeightPx snapshot is seeded at construction, so a component mounting during a warm resume, or with a hardware or floating keyboard already up, lays out correctly on its first frame.
Pinch-zoom shrinks the visual viewport just like a keyboard does. readKeyboardHeight guards this with viewport.scale > 1. If you re-implement the shell for a different platform, replicate this guard, or a zoomed webview will push the composer up by the wrong amount.

openExternal must reject any scheme the allowlist rejects. In a webview, openExternal("javascript:...") is script execution in the app’s own origin — and the URL routinely comes from a model or a tool result.
The allowlist lives in the port, not in one adapter, so every shell is held to it by the contract suite. Never add a blocklist instead — the set of dangerous schemes is open-ended.

The native counterpart

On desktop and web-only builds, readKeyboardHeight(view) is the entire source of the keyboard height. On iOS and Android the keyboard height, safe-area insets, lifecycle, and back-press come from the Native Shell instead — four Tauri events the webview subscribes to by string. The two are complementary, not alternatives. The web shell’s keyboardHeightPx seed still runs at construction so the first frame lays out correctly; the native keyboard-height event feeds every update after it.

Executable Specification

Four conformance tests in adapters/src/conformance/contracts.test.ts pin the snapshot and the guard:

Common Patterns

The live handler and the seed share one function, so a hide is never swallowed.
Layout reads the synchronous snapshot at mount, then subscribes for changes.

Best Practices

A property declared = 0 and only updated by an event reproduces the exact bug it was added to fix: one frame at the wrong height, then a jump. Seed from readKeyboardHeight(view) at construction.
scale > 1 separates zoom from a keyboard. A shell that omits the guard reports a phantom keyboard the moment the user zooms — most visibly at construction, on a page opened already zoomed.
Keep the clamp and the guard in a single readKeyboardHeight. Duplicating the subtraction in the seed and the handler lets the two drift apart.
The most recently registered handler gets first refusal. A Set has no defined order and ships a modal that closes the wrong screen.
parseFloat("") is NaN, and calc(100vh - NaNpx) silently blanks the screen. Every inset path coerces unparseable input to 0.
iOS can kill a suspended app with no further callback, so anything unflushed when onLifecycleChanged reports background is lost.

Mobile Architecture

How the shell is injected at boot.

Capabilities & Gaps

The keyboard snapshot as a closed gap.

Native Shell

The Tauri events that feed the shell on iOS and Android.

Storage & Secrets

Where chats and API keys live.

The Two Seams

How the UI-shell seam is enforced.