diff --git a/README.md b/README.md
index 3df4ffb4..475cb72f 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@ BEAM-on-device mobile framework for Elixir. OTP runs inside your iOS and Android
```mermaid
flowchart TD
A["Your Elixir app
(GenServers, OTP supervision, pattern matching, pipes)"]
- B["Mob.Screen
(GenServer — your logic lives here)"]
+ B["Mob.Screen.Server
(one GenServer per live screen — your logic lives here)"]
C["Mob.Renderer
(component tree → JSON → NIF call)"]
D1["Compose (Android)
native rendering, gestures"]
D2["SwiftUI (iOS)
native rendering, gestures"]
@@ -63,12 +63,16 @@ defmodule MyApp.CounterScreen do
}
end
- def handle_event("tap", %{"tag" => "increment"}, socket) do
+ def handle_info({:tap, :increment}, socket) do
{:noreply, Mob.Socket.assign(socket, :count, socket.assigns.count + 1)}
end
end
```
+A tap on the button delivers `{:tap, :increment}` to `handle_info/2` — the tag
+comes from the `on_tap: {self(), :increment}` tuple in `render/1`, and `self()`
+is this screen's own process.
+
## App entry point
```elixir
@@ -251,10 +255,14 @@ Mob.Test.tap(:"my_app_ios@127.0.0.1", :increment)
## Testing
```elixir
-test "increments count" do
- {:ok, pid} = Mob.Screen.start_link(MyApp.CounterScreen, %{})
- :ok = Mob.Screen.dispatch(pid, "tap", %{"tag" => "increment"})
- assert Mob.Screen.get_socket(pid).assigns.count == 1
+defmodule MyApp.CounterScreenTest do
+ use Mob.ScreenCase
+
+ test "increments count" do
+ view = mount_screen(MyApp.CounterScreen)
+ view = render_info(view, {:tap, :increment})
+ assert assigns(view).count == 1
+ end
end
```
diff --git a/guides/agentic_coding.md b/guides/agentic_coding.md
index 95ea1ec7..155ab702 100644
--- a/guides/agentic_coding.md
+++ b/guides/agentic_coding.md
@@ -5,7 +5,15 @@ verify it worked, decide what to do next. This guide explains how to give an age
full context it needs to work effectively on a Mob app — and why the default approach
most agents reach for will give you worse results.
-## The context problem
+The guide is in two halves. [Working with one agent](#working-with-one-agent)
+covers the loop for a single agent driving a single app — stop there if that's
+you. [Working with agent teams](#working-with-agent-teams) builds on that loop
+for fleets: many agents, shared devices, and the discipline that keeps them
+from trampling each other.
+
+## Working with one agent
+
+### The context problem
An LLM working on a mobile app normally has two options for inspecting the running app:
@@ -23,7 +31,7 @@ exact state, not infer it from pixels.
**The agent should connect to the running Erlang node and ask it directly.**
-## Priming the agent
+### Priming the agent
Before the MCP tools and tunnels, give the agent the mental model of the
project. Each Mob repo has an `AGENTS.md` at its root — a five-minute
@@ -56,12 +64,12 @@ top-of-file note in each `AGENTS.md` instructs the agent to update them in
the same commit as any change that contradicts the guidance — keeping it
up to date is a contract, not a suggestion.
-## Setting up the MCP tools
+### Setting up the MCP tools
The Layer 2 visual tools require two MCP servers to be installed and registered with
your AI agent.
-### ios-simulator-mcp
+#### ios-simulator-mcp
Interacts with the iOS Simulator from outside the app: screenshots, taps, text input,
accessibility tree queries.
@@ -83,7 +91,7 @@ Add to your Claude Code MCP config (`~/.claude.json`, under `mcpServers`):
}
```
-### adb-mcp
+#### adb-mcp
Provides ADB-backed tools for Android: screenshots, UI dumps, shell access, logcat.
@@ -107,7 +115,7 @@ Add to `~/.claude.json`:
}
```
-### Verifying the setup
+#### Verifying the setup
After adding both servers, restart Claude Code and check that the tools are available.
In a conversation, the `mcp__ios-simulator__screenshot` and `mcp__adb__dump_image`
@@ -116,7 +124,7 @@ available to you"* — it should enumerate both server namespaces.
---
-## Prerequisites
+### Prerequisites
Before an agent can inspect the running app, tunnels must be established:
@@ -132,11 +140,11 @@ Node names:
- iOS simulator: `mob_demo_ios@127.0.0.1`
- Android emulator: `mob_demo_android@127.0.0.1`
-## The three-layer inspection stack
+### The three-layer inspection stack
Use these in order. Only go deeper if the layer above doesn't answer your question.
-### Layer 1 — Erlang distribution (always try this first)
+#### Layer 1 — Erlang distribution (always try this first)
`Mob.Test` gives the agent exact knowledge of what's happening inside the running app.
No image parsing, no heuristics, no guessing.
@@ -167,7 +175,7 @@ or directly from an agent that can run shell commands, using:
iex -S mix --eval 'IO.inspect Mob.Test.assigns(:"mob_demo_ios@127.0.0.1")'
```
-### Layer 2 — MCP platform tools (for rendering and layout)
+#### Layer 2 — MCP platform tools (for rendering and layout)
When the question is visual — "does this text overflow?", "is the button in the right
position?", "did the animation play?" — use the platform MCP servers.
@@ -196,14 +204,14 @@ These are available as tools in Claude Code:
| `adb_shell` | Run shell commands on device |
| `adb_logcat` | Tail device logs (Elixir output appears under the `Elixir` tag) |
-### Layer 3 — Raw platform tools (almost never needed)
+#### Layer 3 — Raw platform tools (almost never needed)
`xcrun simctl`, raw `adb shell`, Xcode Instruments. These are what agents reach for
by default — resist it. They give you less information than Layer 1 and are slower
than Layer 2. The only reason to drop here is if the MCP servers aren't configured
or a specific low-level query has no higher-level equivalent.
-## The standard agent loop
+### The standard agent loop
```
1. Edit Elixir source
@@ -212,10 +220,16 @@ or a specific low-level query has no higher-level equivalent.
4. Mob.Test.assigns(node) ← confirm data state is what you expect
5. Mob.Test.tap(node, :some_tag) ← drive an interaction
6. Mob.Test.assigns(node) ← confirm state updated
-7. mcp__ios-simulator__screenshot ← visual check only if layout matters
-8. repeat from 1
+7. Mob.Test.settle(node) ← wait for the frame to commit…
+8. mcp__ios-simulator__screenshot ← …before any visual check (only if layout matters)
+9. repeat from 1
```
+`tap/2` is fire-and-forget, and rendering is committed asynchronously by
+`Mob.Sender` — so before reading anything on the *native* side (screenshots,
+`view_tree/1`, `element_frames/1`, `tap_id/2`), call `Mob.Test.settle(node)`.
+Reading `assigns/1` or `tree/1` doesn't need it.
+
For changes that touch native code (NIFs, Swift, Kotlin):
```
@@ -225,7 +239,160 @@ For changes that touch native code (NIFs, Swift, Kotlin):
4. continue with loop above
```
-## Steering the agent
+### Verify effects, not exit codes
+
+Build and deploy tooling can exit 0 without doing what you meant: a device id
+that matched nothing, a toolchain half-installed, a deploy that quietly went to
+a different simulator. An exit code proves the tool ran; it does not prove the
+app changed. After any deploy, assert the effect before proceeding:
+
+```
+# Wrong approach
+mix mob.deploy && echo "deployed" # exit 0 — but to what?
+
+# The Mob approach — prove the app is up and answering
+mix mob.deploy --device
+mix mob.connect --no-iex
+```
+
+```elixir
+node = :"mob_demo_ios@127.0.0.1"
+Mob.Test.screen(node)
+#=> MobDemo.HomeScreen ← the app exists, the node connects, a screen is live
+# {:badrpc, :nodedown} here means the deploy did NOT land — stop and find out why
+```
+
+For a code push, prove the code changed: bump something observable (a version
+assign, a log line) and read it back through `Mob.Test.assigns/1` before
+trusting any further conclusions.
+
+### The honesty contract
+
+Success means **a handler ran**, not that the call returned `:ok`.
+`Mob.Test.tap/2` returns `:ok` whether or not any screen matched the tag —
+it's a fire-and-forget message send. The only honest assertion is a state
+change:
+
+```elixir
+before = Mob.Test.assigns(node).count
+Mob.Test.tap(node, :increment)
+Mob.Test.settle(node)
+assert Mob.Test.assigns(node).count == before + 1
+```
+
+If the state didn't change, the tap didn't reach a handler — wrong tag, a
+`handle_info/2` clause that doesn't match, or a stale handle. That is a
+first-class diagnostic signal, not a flake to retry.
+
+Coordinate driving is held to the same contract by the framework itself:
+`Mob.Test.tap_xy/3` (and `tap_id/2`, which inherits its contract) returns
+`:ok` only when **the app reacted** — an event reached the BEAM within 300 ms
+of the tap. Everything else is an honest error, never a "probably worked":
+
+- `{:error, :no_view_at_point}` — hit-test found nothing at that coordinate
+- `{:error, :no_element_at_point}` — iOS simulator: a view is there but no
+ accessibility element to activate
+- `{:error, :no_effect}` — the OS accepted the input but no handler ran
+
+Read `Mob.Test.tap_xy/3` before treating a non-`:ok` as a test failure: a
+SwiftUI `Box`/`Row`/`Column` with `on_tap:` has no activate action on the
+simulator, and on a physical iOS device coordinate injection currently
+delivers no touch at all — both legitimately report `{:error, :no_effect}`,
+and `tap/2` (by tag) is the way to drive them.
+
+One assumption to respect: effect detection is **process-wide**. Both the
+state-change assertion and `tap_xy`'s 300 ms effect window count *any* Mob
+event that reaches the BEAM — another agent, a timer, a scroll notification —
+so concurrent activity can false-positive either check. The harness is assumed
+serial: one synthetic interaction in flight at a time, and exactly one agent
+driving a given device (see
+[Working with agent teams](#working-with-agent-teams)).
+
+### Match the evidence to the question
+
+Each question has one cheapest sufficient source of evidence — collect that
+one, not a screenshot of everything:
+
+- **State** ("did the handler run?", "what's in the list?") —
+ `Mob.Test.assigns/1`, `tree/1`. Never pixels.
+- **Layout / geometry** ("is the button below the fold?", "do these
+ overlap?") — `Mob.Test.element_frames/1` and `frame/2` give exact
+ `{x, y, w, h}` per `:id`, no screenshot required. `scroll_info/2` for
+ scroll positions.
+- **Exact color** ("is this Box actually `:primary`?", "did the theme drop
+ the background?") — `Mob.Test.sample_color/2`: real rendered pixels for one
+ element's frame (or an explicit rect), reduced to
+ `%{average:, dominant:, dominant_share:, ...}` as `0xAARRGGBB` integers.
+ Assert on `:dominant` for flat fills, `:average` for gradients/glass, and
+ compare regions against each other to catch a theme regression (two
+ different tokens sampling identical is the bug). iOS-only and debug-build
+ only — a release build deliberately ships no sampling probe.
+- **Holistic appearance** ("does this screen look right?", "did the font
+ apply?") — a screenshot (`Mob.Test.screenshot/2`), compared with tolerance.
+ Pixel colors vary by device profile, scale and alpha compositing; treat
+ exact equality across a whole screenshot as a bug in the test. For a
+ single color *decision*, prefer `sample_color/2` above.
+- **Transitions and animation** ("did the push slide?") — a still proves
+ nothing about motion. Use the MCP `record_video` / `stop_recording`
+ tools, or capture a timed sequence of `screenshot/2` frames and compare.
+- **Human-facing evidence** ("show me it works") — screenshots and
+ recordings. That's their real job; they're the *last* tool for deciding,
+ and the first for demonstrating.
+
+### Simulating lifecycle events
+
+Cold-start and notification paths are drivable without a hand on the device.
+
+For the **in-app half** — your `handle_info/2` clauses — stay in-process:
+
+```elixir
+Mob.Test.send_message(node, {:notification, %{id: "n1", title: "Hi", body: "Hello", data: %{}, source: :push}})
+```
+
+For the **OS half** — delivery while backgrounded, cold-start from a
+notification tap — use the platform tools. iOS simulator, with a payload file:
+
+```bash
+cat > /tmp/note.apns <<'JSON'
+{
+ "Simulator Target Bundle": "com.example.mob_demo",
+ "aps": { "alert": { "title": "Hi", "body": "Hello" } }
+}
+JSON
+xcrun simctl push booted /tmp/note.apns
+```
+
+Android emulator, broadcast to the app's push receiver (or exercise the
+notification shade itself):
+
+```bash
+adb shell am broadcast -p com.example.mob_demo -a com.google.android.c2dm.intent.RECEIVE
+adb shell cmd notification post -S bigtext -t 'Hi' demo_tag 'Hello'
+```
+
+Cold start is the same idea: `xcrun simctl terminate booted ` then
+`launch`, or `adb shell am force-stop ` then `am start`. After any
+restart, re-run `mix mob.connect --no-iex` and re-verify the node answers
+before drawing conclusions.
+
+### Environment discipline
+
+Agents dead-end on toolchain gaps a human shrugs off — a human notices the
+`zig: command not found` buried in build output and installs it; an agent may
+conclude the code is broken. Make the environment complete and declarative:
+
+- **A complete `.tool-versions`.** Erlang and Elixir, plus everything the
+ native builds need: Zig for the Android NIF build, a JDK for Gradle. If a
+ tool is required to build, it belongs in the file — "it was on my PATH" is
+ not reproducible for an agent.
+- **The path-override chain for framework work.** To test a change to the
+ framework itself end-to-end against a real app, point the app at your local
+ checkouts instead of Hex: `MOB_DIR` / `MOB_DEV_DIR` (used by
+ `mix mob.new --local` and the iOS build) and `MOB_NEW_DIR` (local project
+ generator). See [Getting Started](getting_started.md) for the full env-var
+ table.
+
+### Steering the agent
LLMs have extensive training data on `xcrun simctl`, `adb`, UIKit, and Jetpack Compose
testing patterns. They will reach for that toolbox instinctively, especially when asked
@@ -257,7 +424,7 @@ Node names:
Replace `mob_demo` with your actual app name.
-## Why Mob.Test beats screenshots for state inspection
+### Why Mob.Test beats screenshots for state inspection
| | Mob.Test | Screenshot |
|---|---|---|
@@ -272,7 +439,7 @@ Replace `mob_demo` with your actual app name.
Screenshots are for humans and for verifying that the visual output *looks right*.
They are not a substitute for inspecting what the program is actually doing.
-## Worked example: debugging a counter that doesn't update
+### Worked example: debugging a counter that doesn't update
A common first instinct for an agent:
@@ -304,7 +471,7 @@ Mob.Test.tap(node, :increment)
Mob.Test.assigns(node)
#=> %{count: 1}
-# If it's still 0, the handle_event clause isn't matching — check the tag name
+# If it's still 0, the handle_info clause isn't matching — check the tag name
Mob.Test.find(node, "Increment")
#=> [{[0, 1], %{"type" => "button", "on_tap_tag" => "inc"}}]
# Ah — the tag is :inc, not :increment
@@ -313,7 +480,7 @@ Mob.Test.find(node, "Increment")
The distribution layer tells you exactly what happened and why. No image comparison,
no inference.
-## Quick reference: on_tap tags
+### Quick reference: on_tap tags
Tags come from `on_tap: {self(), :tag_atom}` in the render tree. To see all widgets
and their tags on the current screen, use the full snapshot:
@@ -327,3 +494,79 @@ Mob.Test.inspect(node)
Or just read the screen's `render/1` function — every interactive widget has a tag
in its props. The tag atom in `on_tap: {self(), :my_tag}` is what you pass to
`Mob.Test.tap(node, :my_tag)`.
+
+## Working with agent teams
+
+Everything above assumes one agent, one app, one loop. This half is about
+fleets — multiple agents (or one orchestrator with subagents) working the same
+codebase and the same devices. It builds on Part 1's loop; the loop itself
+doesn't change, but who may run it against what does.
+
+### One driver per device
+
+Erlang distribution happily lets *many* host nodes attach to one running app —
+inspection is cheap and concurrent. Driving is not. The honesty contract's
+effect detection is process-wide: "assigns changed after my tap" is only
+evidence if yours was the only tap. Two agents driving one device produce
+false effect signals for both, in both directions.
+
+So serialize UI driving per device:
+
+- **Exactly one agent drives a given device at a time.** Read-only inspection
+ (`assigns/1`, `tree/1`, `screenshot/2`) from others is fine; taps,
+ navigation, and `send_message/2` are not.
+- **Use a lease.** A claim file, a lock, an orchestrator-assigned slot —
+ the mechanism matters less than the rule: acquire before driving, release
+ when done, and put the device id in the lease so it's auditable.
+- **Humans outrank agents on physical hardware.** A person holding the phone
+ wins; agents fall back to simulators/emulators or wait for the lease.
+
+### Unique node names per agent session
+
+Every `mix mob.connect` session names its local node — the default is
+`mob_dev@127.0.0.1`, and two sessions with the same name cannot both register
+with EPMD. Give each agent session its own name:
+
+```bash
+mix mob.connect --no-iex --name agent_a@127.0.0.1
+mix mob.connect --no-iex --name agent_b@127.0.0.1
+```
+
+This also makes `Node.list/0` on the device an audit trail: you can see who is
+attached.
+
+### Per-task git worktrees
+
+Agents should never share a working tree — with each other, or with a human's
+primary checkout. A half-finished edit in a shared tree becomes another
+agent's mysterious compile error. `git worktree add ../worktrees/ -b
+ origin/master` gives each task an isolated tree on its own branch for
+the cost of a checkout; clean it up when the branch merges.
+
+### Hot-push fan-out: a single-developer convenience
+
+`mix mob.push` (and `mix mob.watch`) connect to **every** running node of the
+app they can find and push changed modules to all of them — there is no
+per-device scoping flag. For one developer with one device, that's the point.
+With a fleet attached, it's cross-contamination: one agent's probe module
+lands on physical devices and on other agents' targets. (In-memory only —
+a restart clears it — but the other agents' evidence is now polluted.)
+
+Fleet rule: treat `mob.push`/`mob.watch` as single-developer conveniences.
+Agents in a fleet deploy per device with `mix mob.deploy --device `, or
+hot-push over their own distribution connection (`nl/1` from their named
+session pushes only to the nodes *that session* is connected to).
+
+### Durable artifacts outlive the context window
+
+An agent's context ends; the next agent starts cold. Anything discovered but
+not written down is re-discovered at full price — or worse, contradicted.
+Conclusions belong where the next agent (or human) will find them:
+
+- **PR comments and descriptions** for "why this change, what was tried".
+- **Decision records** (this repo's `decisions/`) for anything the next
+ change must not accidentally undo.
+- **Committed findings** — a failing test reproducing a bug is worth more
+ than a paragraph describing it.
+
+The handoff medium between agents is the repository, not the conversation.
diff --git a/guides/components.md b/guides/components.md
index 0df96aec..92dda500 100644
--- a/guides/components.md
+++ b/guides/components.md
@@ -295,6 +295,7 @@ Displays a string.
| `text` | string | The text to display (required) |
| `text_size` | number / token | Font size |
| `text_color` | color | Text color |
+| `font` | token / string | A named font token from `Mob.Theme`'s `fonts:` map (e.g. `:heading`), or a raw platform font name. See [Styling → Custom fonts](styling.md#custom-fonts). |
| `font_weight` | `"regular"` / `"medium"` / `"bold"` | Font weight |
| `text_align` | `"left"` / `"center"` / `"right"` | Horizontal alignment |
@@ -401,6 +402,59 @@ def handle_info({:change, :volume_changed, value}, socket) do
end
```
+## Overlay components
+
+### `:sheet`
+
+A native modal bottom sheet (iOS `.sheet`, Android Material 3
+`ModalBottomSheet`) that composes ordinary Mob nodes as its content. Build one
+with `Mob.UI.sheet/2` or the `` tag.
+
+There is no `presented` boolean: **presence in the render tree is
+presentation**. Rendering the sheet node presents it, a re-render that still
+includes it updates its content in place, and removing it from the tree
+dismisses it. So sheet visibility is an ordinary assign plus `:if`:
+
+```elixir
+def render(assigns) do
+ dismiss = {self(), :sheet_dismissed}
+ ~MOB"""
+
+
+
+
+
+
+ """
+end
+
+def handle_info({:dismiss, :sheet_dismissed}, socket) do
+ # The user swiped the sheet down — mirror that in your state, or the next
+ # render will present it again.
+ {:noreply, Mob.Socket.assign(socket, :show_sheet, false)}
+end
+```
+
+| Prop | Type | Description |
+|------|------|-------------|
+| `detents` | list | Stops the sheet can rest at: a subset of `[:medium, :large]`, or the exclusive content-height detent `[:content]` / `[{:content, max_height: n}]`. Default `[:medium, :large]`. Invalid detents raise, both in `Mob.UI.sheet/2` and again at render time. |
+| `on_dismiss` | `{pid, tag}` | Delivered as `{:dismiss, tag}` to `handle_info/2`, exactly once, when the user dismisses the sheet (swipe-down, back gesture, outside tap) |
+| `background` | color | Sheet container color |
+| `scrim` | color | Dimming-layer color. Applied exactly on Android; **iOS cannot set the system dimming opacity** and stays system-black |
+| `corner_radius` | number / token | Top-corner radius |
+| `drag_indicator_color` / `_width` / `_height` / `_rail_height` | color / numbers | Custom drag-indicator capsule. All four together, or omit all four for the platform default |
+| `ios` / `android` | map | Per-platform overrides of the style props above |
+
+A `:content` detent sizes the sheet from its content's *intrinsic* height —
+it hugs short content and caps at `max_height` (and at live screen geometry).
+Because a scrollable child (`scroll`, `lazy_list`) reports its full content
+height, it expands inside the sheet rather than scrolling independently; use
+`:medium`/`:large` when the sheet's body is itself scrollable. On iOS a
+content sheet presents at `:medium` for its first frame and resizes once the
+content has been measured.
+
+See `Mob.UI.sheet/2` for the full option reference and validation rules.
+
## Native view components
### `:webview`
@@ -731,6 +785,22 @@ end
| `on_focus: {pid, tag}` | `{:tap, tag}` |
| `on_blur: {pid, tag}` | `{:tap, tag}` |
+### Handle limits
+
+The native layer stores event handlers in fixed-size pools, per committed
+frame:
+
+- **256 interactive handles per frame.** Every `on_tap`, `on_change`,
+ `on_focus`, etc. in the rendered tree registers one handle. Past the cap,
+ the element still renders but its handler is silently unwired (a native
+ error is logged); it does not crash the screen. In practice only an
+ unvirtualized long list or a very large form gets there — use `:list` /
+ `:lazy_list` for long content.
+- **256 native component slots.** `Mob.UI.native_view/2` / `Mob.Component`
+ instances each take a slot. A full pool returns
+ `{:error, :component_slots_exhausted}`; the framework logs and fails just
+ that one component, leaving the screen alive.
+
### Sub-component event isolation (planned, not yet implemented)
Per-subtree event isolation, where a render subtree owns its own `handle_info/2` so its events route to a dedicated process instead of the screen, is planned but not yet implemented. (Distinct from `Mob.Composite`, the tag-composite mechanism under "Defining your own components" above, which exists today for reusable widgets and custom tags; and from `Mob.Component`, the existing native-view behaviour.) Until then, use the `tag` field to distinguish events from different parts of the same screen:
diff --git a/guides/device_capabilities.md b/guides/device_capabilities.md
index 1b670c49..54befbc0 100644
--- a/guides/device_capabilities.md
+++ b/guides/device_capabilities.md
@@ -52,7 +52,7 @@ end
`Mob.Haptic.trigger/2` fires synchronously (no `handle_info` needed) and returns the socket:
```elixir
-def handle_event("tap", %{"tag" => "purchase"}, socket) do
+def handle_info({:tap, :purchase}, socket) do
socket = Mob.Haptic.trigger(socket, :success)
{:noreply, socket}
end
@@ -66,13 +66,13 @@ iOS uses `UIImpactFeedbackGenerator` / `UINotificationFeedbackGenerator`. Androi
```elixir
# Write to clipboard
-def handle_event("tap", %{"tag" => "copy"}, socket) do
+def handle_info({:tap, :copy}, socket) do
socket = Mob.Clipboard.write(socket, socket.assigns.code)
{:noreply, socket}
end
# Read from clipboard — result arrives in handle_info
-def handle_event("tap", %{"tag" => "paste"}, socket) do
+def handle_info({:tap, :paste}, socket) do
socket = Mob.Clipboard.read(socket)
{:noreply, socket}
end
@@ -87,7 +87,7 @@ end
Opens the platform's native share sheet (iOS: `UIActivityViewController`, Android: `ACTION_SEND`):
```elixir
-def handle_event("tap", %{"tag" => "share"}, socket) do
+def handle_info({:tap, :share}, socket) do
socket = Mob.Share.sheet(socket, text: "Check out this app!", url: "https://example.com")
{:noreply, socket}
end
diff --git a/guides/event_audit.md b/guides/event_audit.md
index 2686e850..b61867ee 100644
--- a/guides/event_audit.md
+++ b/guides/event_audit.md
@@ -100,7 +100,7 @@ can be removed.
## Migration path for `Mob.List`
Currently `Mob.List` is a render helper, not a stateful component. Each row
-gets `on_tap: {screen_pid, {:list, list_id, :select, index}}`. `Mob.Screen`
+gets `on_tap: {screen_pid, {:list, list_id, :select, index}}`. `Mob.Screen.Server`
has hardcoded knowledge of this shape and re-emits as `{:select, list_id, index}`.
Under the new event model, this becomes a stateful component (planned, not in
diff --git a/guides/getting_started.md b/guides/getting_started.md
index ea8da311..9373f21c 100644
--- a/guides/getting_started.md
+++ b/guides/getting_started.md
@@ -309,15 +309,15 @@ One thing to be aware of: a mixed app has **two distinct forms of navigation**.
`<.link navigate={...}>` or `push_navigate(...)`. Lives entirely inside the
LiveView WebSocket; the WebView's URL changes but the native nav stack
doesn't.
- * **Native navigation** — `Mob.Nav.push/2`, `pop/1`, tab bars, drawers.
- Lives in the native nav controller; the WebView is just one screen on
- that stack.
+ * **Native navigation** — `Mob.Socket.push_screen/2,3`, `pop_screen/1`,
+ tab bars, drawers. Lives in the Mob navigation stack; the WebView is just
+ one screen on that stack.
The two stacks don't talk to each other (by default but you control both sides so if you _really_ want to you could make that happen). A Phoenix route change inside a
-WebView doesn't push a native screen, and a `Mob.Nav.push` doesn't navigate
+WebView doesn't push a native screen, and a `push_screen` doesn't navigate
the WebView. Plan crossings explicitly: a tap inside the LiveView that should
push a native screen sends a `mob_message` event up to the hosting
-`Mob.Screen`, which calls `Mob.Nav.push/2`; a native back-button in a parent
+`Mob.Screen`, which calls `Mob.Socket.push_screen/2,3`; a native back-button in a parent
screen pops the WebView screen as a whole, not the route inside it.
### Extra prerequisite
diff --git a/guides/navigation.md b/guides/navigation.md
index f288f838..212612a2 100644
--- a/guides/navigation.md
+++ b/guides/navigation.md
@@ -31,7 +31,7 @@ The first argument is the stack's name atom — it becomes a valid navigation de
### Tab bar
-A bottom tab bar (iOS: `UITabBarController`, Android: `NavigationBar`) containing multiple named stacks:
+Multiple named stacks, each with its own independent navigation history:
```elixir
tab_bar([
@@ -41,9 +41,20 @@ tab_bar([
])
```
+Each declared stack keeps its own history *and* its own live screens, so
+switching away from a tab and back restores exactly where you were — see
+[Tabs and multi-stack state](#tabs-and-multi-stack-state) below.
+
+> **No automatic tab chrome yet.** The runtime fully backs `tab_bar/1` —
+> per-stack state is kept and `switch_tab/2` works — but declaring it does not
+> yet draw a bottom tab bar. Switching is programmatic via
+> `Mob.Socket.switch_tab/2` for now; to draw chrome yourself, render the
+> `:tab_bar` *widget* in your screens and wire its `on_tab_select` to
+> `switch_tab/2` (see [Styling → Tab bar props](styling.md#tab-bar-props-tab_bar)).
+
### Drawer
-A side drawer (Android: `ModalNavigationDrawer`, iOS: custom slide-in panel) containing multiple named stacks:
+The same shape with drawer semantics — multiple named stacks:
```elixir
drawer([
@@ -52,6 +63,9 @@ drawer([
])
```
+The multi-stack state rules below apply identically. As with `tab_bar/1`, no
+drawer chrome is drawn yet; switching is programmatic.
+
### Platform-specific navigation
Pass different structures per platform:
@@ -71,7 +85,7 @@ Navigation is queued by returning a modified socket from any callback. The frame
Navigate to a new screen, pushing it onto the stack:
```elixir
-def handle_event("tap", %{"tag" => "open_detail"}, socket) do
+def handle_info({:tap, :open_detail}, socket) do
{:noreply, Mob.Socket.push_screen(socket, MyApp.DetailScreen, %{id: socket.assigns.id})}
end
```
@@ -93,7 +107,7 @@ The params map is passed to the destination screen's `mount/3`.
Return to the previous screen:
```elixir
-def handle_event("tap", %{"tag" => "back"}, socket) do
+def handle_info({:tap, :back}, socket) do
{:noreply, Mob.Socket.pop_screen(socket)}
end
```
@@ -126,7 +140,7 @@ Replace the entire navigation stack with a new root. No back button, no history.
```elixir
# After login — go to home with no way to navigate back to the login screen
-def handle_event("tap", %{"tag" => "logged_in"}, socket) do
+def handle_info({:tap, :logged_in}, socket) do
{:noreply, Mob.Socket.reset_to(socket, MyApp.HomeScreen)}
end
```
@@ -140,21 +154,63 @@ Mob.Socket.reset_to(socket, MyApp.PortfolioScreen, %{}, transition: :push)
### `switch_tab/2`
-Switch to a named tab in a tab bar or drawer layout:
+Switch to a named stack in a tab bar or drawer layout:
```elixir
Mob.Socket.switch_tab(socket, :settings)
```
+The first switch to a stack mounts its declared `:root`; later switches restore
+the stack exactly as you left it. Switching to the stack you are already on, or
+to a name no stack declares, is a no-op. A tab switch is a swap, not a move
+along a stack, so it renders with no push/pop animation.
+
+## Tabs and multi-stack state
+
+With a `tab_bar/1` or `drawer/1` layout, every declared stack owns its own
+history and its own live screens (`Mob.Nav` holds this state). The rules:
+
+- **Stacks materialize on first visit.** A declared stack has no screen and has
+ never mounted until it is first switched to — matching `UITabBarController`,
+ which does not instantiate a tab's view controller until selected. From the
+ second visit onward its state is retained for the app's lifetime.
+- **Switching away parks the whole stack.** The parked stack's screen processes
+ stay alive with their state, but their renders are not committed — an
+ inactive tab holds state without painting. Switching back restores the exact
+ screen and history, without re-mounting.
+- **Histories are independent.** `pop_screen/1`, `pop_to/2`, and
+ `pop_to_root/1` operate on the active stack only; nothing can pop across a
+ stack boundary.
+- **Back at a secondary stack's root returns to the first stack.** The system
+ back gesture at the root of any declared stack other than the first switches
+ to the first stack instead of exiting the app (the Android convention). Only
+ back at the first stack's root exits.
+- **A root outside the layout gets a private stack.** If `start_root/1` mounts
+ a screen no stack declares (a splash, login, or deep-link target), it is
+ parked under a private orphan stack when you first `switch_tab/2` away. Its
+ state is preserved, every declared root stays reachable, and the orphan is
+ never itself a switch target.
+
+Known gaps, tracked on the epic: `reset_to/2` does not re-derive which stack
+its destination belongs to (MOB-115); parked screens miss `terminate/2` and
+persisted-state sync (MOB-116); re-selecting the active tab is a no-op rather
+than popping that stack to its root (MOB-117).
+
## Navigation animations
The framework automatically picks the right animation based on the navigation action:
- **Push** — slide in from right (iOS) / slide up (Android)
- **Pop** — reverse slide
- **Reset** — cross-fade (no directional animation, no back history)
+- **Tab switch** — none (a swap, not a move along a stack)
`reset_to/4` can override only the animation with `transition: :push` or
-`transition: :pop`; it still discards navigation history.
+`transition: :pop`; it still discards navigation history. Any other transition
+value raises `ArgumentError` — including `:none`, which native would treat as
+"not navigation" and diff the incoming tree into the outgoing screen's view
+identities. A navigation's animation survives coalescing: an ordinary re-render
+(a timer tick, a component update) queued behind a push cannot swallow the
+push's animation.
## Passing data on pop
diff --git a/guides/screen_lifecycle.md b/guides/screen_lifecycle.md
index c45d5b6d..aad6fc03 100644
--- a/guides/screen_lifecycle.md
+++ b/guides/screen_lifecycle.md
@@ -142,7 +142,15 @@ The default implementation (from `use Mob.Screen`) raises for any unhandled even
@callback terminate(reason :: term(), socket :: Mob.Socket.t()) :: term()
```
-Called when the screen process is about to stop. Use it for cleanup — cancel timers, release resources. The return value is ignored.
+Called when the screen process is about to stop — when the screen is popped
+from its stack, or when navigation shuts down and takes its linked screens with
+it. Use it for cleanup — cancel timers, release resources. The return value is
+ignored. Persisted screens (`use Mob.Screen, vsn: N` or `persist: true`) also
+dump their state here, so their assigns survive an app exit.
+
+Only the screen leaving the stack is stopped: on a pop, the screens still below
+it in the history stay alive, which is what lets pop restore the previous
+screen's state without re-mounting it.
The default is a no-op. Most screens don't need to implement this.
@@ -208,6 +216,22 @@ def render(assigns) do
end
```
+## Crashes and restarts
+
+A crash in a screen callback kills that screen's process only. The router
+observes the exit, restarts the screen in the same navigation slot with its
+original mount params, and repaints. The restarted screen runs `mount/3` again
+and loses its assigns — persisted screens get their dumped state back through
+`load_state/2` — and the restart is logged at error, because a form clearing
+itself is visible to the user. Restarts are capped (5 in 10 seconds per screen)
+so a screen that crashes on every render cannot spin.
+
+Because each screen owns its own process, `self()` in a callback is that
+screen's pid. A task or timer started by a screen delivers to that screen —
+even if it's parked under an inactive tab — and if the screen has been popped
+and stopped, the BEAM drops the message rather than delivering it to whatever
+screen is now current.
+
## System back
-The framework handles the system back gesture (Android hardware back / swipe, iOS edge-pan) automatically. If there is a screen behind the current one in the navigation stack, it pops. If the stack is empty, the app exits. You do not need to handle `{:mob, :back}` unless you want to override this behaviour.
+The framework handles the system back gesture (Android hardware back / swipe, iOS edge-pan) automatically. If there is a screen behind the current one in the active stack, it pops. At the root of a secondary stack in a `tab_bar/1`/`drawer/1` layout, back switches to the first stack (the Android convention — see [Navigation](navigation.md#tabs-and-multi-stack-state)). At the root of the first (or only) stack, the app exits. You do not need to handle `{:mob, :back}` unless you want to override this behaviour.
diff --git a/guides/testing.md b/guides/testing.md
index 6e92f1de..48be16d9 100644
--- a/guides/testing.md
+++ b/guides/testing.md
@@ -2,9 +2,53 @@
Mob supports two levels of testing: unit tests for screen logic (no device required) and live inspection of a running app via Erlang distribution.
-## Unit testing screens
+## Unit testing screens with Mob.ScreenCase
-`Mob.Screen.start_link/2` starts a screen process in `:no_render` mode — it runs all Elixir callbacks but skips NIF calls. Use it in `ExUnit` tests:
+`Mob.ScreenCase` is the blessed way to unit-test a screen in the BEAM — the
+screen-level analog of `Phoenix.LiveViewTest`. It drives `mount/3`,
+`handle_event/3`, and `handle_info/2` directly and gives you query helpers
+whose vocabulary matches `Mob.Test` (`assigns/1`, `tree/1`, `find/3`,
+`text/1`), so a test reads the same whether it runs in-BEAM in milliseconds or
+against a real device:
+
+```elixir
+defmodule MyApp.CounterScreenTest do
+ use Mob.ScreenCase
+
+ test "increment bumps the count and the rendered text" do
+ view = mount_screen(MyApp.CounterScreen)
+ assert assigns(view).count == 0
+
+ # A tap arrives as a message — render_info is the in-BEAM equivalent:
+ view = render_info(view, {:tap, :increment})
+ assert assigns(view).count == 1
+ assert text(view) =~ "Count: 1"
+
+ # cheap native-contract check: every node the screen emits is a
+ # type the Compose / SwiftUI layer actually renders.
+ assert_renderable(view)
+ end
+
+ test "save navigates to the detail screen" do
+ view = mount_screen(MyApp.HomeScreen)
+ view = render_info(view, {:tap, :open_detail})
+ assert navigated_to(view) == MyApp.DetailScreen
+ end
+end
+```
+
+`device_view/1` wraps a running device node in the same `View` handle, so the
+query helpers (`assigns/1`, `tree/1`, `assert_renderable/2`, `navigated_to/1`)
+work against live hardware behind a `@tag :on_device`. See `Mob.ScreenCase`
+for the full API.
+
+## Unit testing with a real screen process
+
+When you want the actual GenServer semantics (messages through a mailbox,
+navigation applied by the router), `Mob.Screen.start_link/2` starts real
+screen processes in `:no_render` mode — it runs all Elixir callbacks but skips
+NIF calls. The returned pid is the navigation owner, which starts one process
+per live screen behind it. Use it in `ExUnit` tests:
```elixir
defmodule MyApp.CounterScreenTest do
@@ -17,17 +61,19 @@ defmodule MyApp.CounterScreenTest do
socket = Mob.Screen.get_socket(pid)
assert socket.assigns.count == 0
- # Dispatch an event
- :ok = Mob.Screen.dispatch(pid, "tap", %{"tag" => "increment"})
+ # Dispatch an event (needs a handle_event("increment", ...) clause)
+ :ok = Mob.Screen.dispatch(pid, "increment", %{})
# Verify updated state
socket = Mob.Screen.get_socket(pid)
assert socket.assigns.count == 1
end
- test "navigates to detail on tap" do
+ test "navigates to detail" do
{:ok, pid} = Mob.Screen.start_link(MyApp.HomeScreen, %{})
- :ok = Mob.Screen.dispatch(pid, "tap", %{"tag" => "open_detail"})
+
+ # dispatch/3 is synchronous — navigation is applied before it returns
+ :ok = Mob.Screen.dispatch(pid, "open_detail", %{})
assert Mob.Screen.get_current_module(pid) == MyApp.DetailScreen
end
@@ -49,14 +95,17 @@ test "handles location update" do
{:ok, pid} = Mob.Screen.start_link(MyApp.MapScreen, %{})
send(pid, {:location, %{lat: 43.6532, lon: -79.3832, accuracy: 10.0, altitude: 80.0}})
- # handle_info is async — wait for the message to process
- :sys.get_state(pid) # sync point: blocks until GenServer is idle
+ # handle_info is async, but get_socket/1 calls into the screen process, so
+ # its reply queues behind the message you just sent — no explicit sync needed.
socket = Mob.Screen.get_socket(pid)
assert socket.assigns.location.lat == 43.6532
end
```
+For a pure-logic test with no processes at all, `Mob.ScreenCase.render_info/2`
+(above) drives the same callback synchronously.
+
## Live inspection with Mob.Test
After `mix mob.connect`, `Mob.Test` gives you a remote view into the running app.
@@ -80,7 +129,7 @@ Mob.Test.inspect(node) # full snapshot: screen + assigns + nav_history + tree
Mob.Test.tap(node, :increment)
```
-The tag atom comes from `on_tap: {self(), :increment}` in the screen's `render/1`. Fire-and-forget — does not block.
+The tag atom comes from `on_tap: {self(), :increment}` in the screen's `render/1`. Fire-and-forget — does not block. Follow with `settle/2` (below) before reading the native side.
### Navigation
@@ -154,15 +203,60 @@ Mob.Test.send_message(node, {:alert, :dismiss})
Mob.Test.send_message(node, {:my_event, %{key: "value"}})
```
-`send_message/2` is fire-and-forget. Use `:sys.get_state` as a sync point if you need to wait before reading state. Pass the screen pid retrieved via `Mob.Test`:
+`send_message/2` is fire-and-forget. Use `Mob.Test.settle/2` as a sync point if
+you need to wait before reading state:
```elixir
Mob.Test.send_message(node, {:permission, :camera, :granted})
-pid = Mob.Test.screen_pid(node)
-:rpc.call(node, :sys, :get_state, [pid]) # blocks until the GenServer is idle
+Mob.Test.settle(node)
Mob.Test.assigns(node)
```
+### Settling: waiting for a frame to land
+
+`settle/2` blocks until the app has finished processing and the current frame
+is committed. Three processes are involved since the screen-process
+architecture: the navigation owner (registered as `:mob_screen`) forwards the
+event, the screen process builds the tree, and `Mob.Sender` commits it —
+so a bare `:sys.get_state(:mob_screen)` is no longer a sufficient sync point.
+Use `settle/2` after any fire-and-forget call (`tap/2`, `back/1`,
+`send_message/2`) before reading the *native* side (`view_tree/1`,
+`screenshot/2`, `tap_id/2`, `element_frames/2`); `tree/1` and `assigns/1`
+re-render in-process and don't need it.
+
+```elixir
+Mob.Test.tap(node, :save)
+Mob.Test.settle(node)
+{:ok, png} = Mob.Test.screenshot(node)
+```
+
+### Coordinate taps report observed effect
+
+`Mob.Test.tap_xy/3` — and `tap_id/2`, which inherits its contract — returns
+`:ok` only when the app **reacted**: an event reached the BEAM within 300 ms of
+the tap. Anything else is an honest error tuple (`{:error, :no_view_at_point}`,
+`{:error, :no_element_at_point}`, `{:error, :no_effect}`), never a phantom
+success. Read `Mob.Test.tap_xy/3`'s platform notes before treating a
+non-`:ok` as a failure — some targets (a SwiftUI `Box` with `on_tap:`, any
+coordinate on a physical iOS device) legitimately report `{:error, :no_effect}`
+and should be driven with `tap/2` by tag instead.
+
+### Sampling rendered colors
+
+`Mob.Test.sample_color/2` reads the pixels the app actually drew for an
+element's frame (or an explicit `{x, y, w, h}` rect) and reduces them to
+`%{average:, dominant:, dominant_share:, distinct:, pixels:}`, all
+`0xAARRGGBB`. It exists because the view tree cannot answer color questions on
+iOS 26 SwiftUI — use it to assert a theme token actually painted, or to catch
+a regression where two different tokens render identically. iOS-only,
+debug-build only.
+
+```elixir
+{:ok, %{dominant: color, dominant_share: share}} = Mob.Test.sample_color(node, "my-card")
+assert share > 0.8 # a flat fill, so :dominant is the background
+assert color == 0xFF2196F3 # the ARGB the theme resolves :primary to
+```
+
### Native UI interaction
`Mob.Test.tap_native/1` locates an element via the iOS accessibility tree and sends a real touch event. **iOS only.** Requires `idb` — install it with `brew install facebook/fb/idb-companion`.
diff --git a/guides/theming.md b/guides/theming.md
index 6b68b3af..08522af2 100644
--- a/guides/theming.md
+++ b/guides/theming.md
@@ -56,6 +56,27 @@ Mob's design token system lets you control color, spacing, and typography across
| `:radius_lg` | 16 |
| `:radius_pill` | 100 |
+**Font tokens** — named fonts declared in the theme's `fonts:` map and passed
+via the `font:` prop. `fonts[:default]` applies app-wide to any node that
+doesn't set its own `font:`, and `font_fallback:` is an ordered list of names
+tried when the resolved font can't be loaded on-device:
+
+```elixir
+use Mob.App,
+ theme: [
+ fonts: %{
+ default: Mob.Theme.font("Inter-Regular", from_file: "priv/fonts/Inter-Regular.ttf"),
+ heading: Mob.Theme.font("Inter-Bold", from_file: "priv/fonts/Inter-Bold.ttf")
+ }
+ ]
+
+# Then in any screen:
+%{type: :text, props: %{text: "Section", font: :heading}, children: []}
+```
+
+See [Styling → Custom fonts](styling.md#custom-fonts) for the full story
+(file placement, `Mob.Theme.font/2`, plugin default fonts, fallback rules).
+
## Using tokens in components
Pass token atoms as prop values for color, spacing, radius, and text size props. The renderer resolves them at render time:
diff --git a/lib/mob/app.ex b/lib/mob/app.ex
index 2963634e..c071a0cd 100644
--- a/lib/mob/app.ex
+++ b/lib/mob/app.ex
@@ -256,8 +256,12 @@ defmodule Mob.App do
@doc """
Declare a tab bar containing multiple named stacks.
- Each branch must be a `stack/2` map. Renders as a bottom NavigationBar on
- Android and a UITabBarController on iOS.
+ Each branch must be a `stack/2` map. Each declared stack keeps its own
+ navigation history and its own live screens; switch between them with
+ `Mob.Socket.switch_tab/2` (see `Mob.Nav` for the state model).
+
+ No tab-bar chrome is drawn yet — the runtime fully backs the declaration,
+ but switching is programmatic for now.
"""
@spec tab_bar([map()]) :: map()
def tab_bar(branches) when is_list(branches) do
@@ -267,8 +271,9 @@ defmodule Mob.App do
@doc """
Declare a side drawer containing multiple named stacks.
- Renders as a ModalNavigationDrawer on Android. iOS uses a custom slide-in
- panel (native UIKit drawer support deferred).
+ Same multi-stack semantics as `tab_bar/1`; the two differ only in intended
+ chrome. No drawer chrome is drawn yet — switching is programmatic via
+ `Mob.Socket.switch_tab/2` for now.
"""
@spec drawer([map()]) :: map()
def drawer(branches) when is_list(branches) do
diff --git a/lib/mob/test.ex b/lib/mob/test.ex
index 852575a7..a28cf743 100644
--- a/lib/mob/test.ex
+++ b/lib/mob/test.ex
@@ -50,8 +50,9 @@ defmodule Mob.Test do
## Tap vs send_message
- `tap/2` is for UI interactions that go through `handle_event/3` via the native
- tap registry. `send_message/2` delivers any term directly to `handle_info/2`.
+ `tap/2` sends the same `{:tap, tag}` message a native tap produces, so it
+ arrives in the screen's `handle_info/2` exactly like a real button press.
+ `send_message/2` delivers any term to `handle_info/2`.
Use `send_message/2` to simulate async results from device APIs (camera, location,
notifications, etc.) without having to trigger the actual hardware.
diff --git a/mix.exs b/mix.exs
index 16b601fb..32c345e5 100644
--- a/mix.exs
+++ b/mix.exs
@@ -187,7 +187,8 @@ defmodule Mob.MixProject do
Mob.Theme.Dark,
Mob.Theme.Adaptive
],
- Navigation: [Mob.Nav, Mob.Nav.Registry],
+ Navigation: [Mob.Nav, Mob.Nav.Registry, Mob.Router],
+ "Runtime Processes": [Mob.Screen.Server, Mob.Listener, Mob.Sender],
Plugins: [Mob.Plugins, Mob.Plugins.Supervisor, Mob.Plugins.Lifecycle],
"Device APIs": [
Mob.Haptic,
@@ -198,7 +199,7 @@ defmodule Mob.MixProject do
Mob.Audio,
Mob.Motion
],
- "Testing & Debugging": [Mob.Test],
+ "Testing & Debugging": [Mob.Test, Mob.ScreenCase],
Tooling: [Mob.Formatter],
Internals: [Mob.Dist, Mob.NativeLogger, Mob.List, Mob.Sigil]
]