From 5b5f84a7454d5bf879d7e9ce5c3ae1c66d311540 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sun, 30 Aug 2026 01:05:54 -0600 Subject: [PATCH 1/7] docs(navigation): multi-stack state, honest tab/drawer claims, back semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit navigation.md still described tab_bar/drawer as rendering native chrome (UITabBarController / NavigationBar) — since 0.7.33 the runtime backs the declaration with real per-stack state but draws no chrome; switching is programmatic. New 'Tabs and multi-stack state' section documents lazy materialization, parking, independent histories, back-at-secondary-root, the orphan stack, and the MOB-115/116/117 gaps. Directional-reset docs gain the ArgumentError validation and transition-survives-coalescing behavior (#100/#103). screen_lifecycle.md gains crash/restart semantics (per-screen isolation, restart cap, re-mount + load_state), per-screen self() and message delivery, terminate/2 reality (pop stops the leaving screen only), and multi-stack system-back. Mob.App.tab_bar/1 and drawer/1 docstrings no longer claim chrome that is not drawn. Co-Authored-By: Claude Fable 5 --- guides/navigation.md | 70 ++++++++++++++++++++++++++++++++++---- guides/screen_lifecycle.md | 28 +++++++++++++-- lib/mob/app.ex | 13 ++++--- 3 files changed, 98 insertions(+), 13 deletions(-) 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/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 From 7ea6d57f976d5d7b10c3f15eaec047930a9ea094 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sun, 30 Aug 2026 01:06:14 -0600 Subject: [PATCH 2/7] docs(testing): document Mob.ScreenCase and settle/2; remove nonexistent screen_pid/1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The testing guide never mentioned Mob.ScreenCase (#44), the blessed in-BEAM unit-test path — it now leads the guide. The old sync-point advice referenced Mob.Test.screen_pid/1, which does not exist, and :sys.get_state on :mob_screen, which stopped being sufficient when rendering moved to Mob.Sender (MOB-110) and :mob_screen became the navigation owner (MOB-112); both are replaced with Mob.Test.settle/2 and an explanation of the three processes it drains. Unit-test examples updated for the process model (dispatch is synchronous; get_socket is the natural sync point after send). Mob.Test's moduledoc claimed tap/2 goes through handle_event/3; it sends {:tap, tag} to handle_info/2 like a real native tap. Co-Authored-By: Claude Fable 5 --- guides/testing.md | 91 ++++++++++++++++++++++++++++++++++++++++------- lib/mob/test.ex | 5 +-- 2 files changed, 82 insertions(+), 14 deletions(-) diff --git a/guides/testing.md b/guides/testing.md index 6e92f1de..28affd64 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,33 @@ 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) +``` + ### 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/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. From bf4714ae8fae60de8e5a3872ed02f51b5ddc9a17 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sun, 30 Aug 2026 01:06:14 -0600 Subject: [PATCH 3/7] docs(components,theming): Sheet section, handle-pool limits, font tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit components.md had no coverage of Mob.UI.sheet/2 (0.7.29) or intrinsic content detents (0.7.32) beyond the surface-matrix row — new ':sheet' section documents presence-is-presentation, detents including [:content] / [{:content, max_height: n}], exactly-once {:dismiss, tag}, and the iOS scrim limitation. New 'Handle limits' section covers the 256-handle tap pool and the 256-slot native component pool with {:error, :component_slots_exhausted} (0.7.28 behavior). :text gains the font prop (named font tokens, 0.7.25). theming.md never mentioned fonts — adds the fonts:/font_fallback: token type with a pointer to Styling → Custom fonts. Co-Authored-By: Claude Fable 5 --- guides/components.md | 70 ++++++++++++++++++++++++++++++++++++++++++++ guides/theming.md | 21 +++++++++++++ 2 files changed, 91 insertions(+) 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/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: From 80d1a116b58dbbe7bf90936366b8fdc9b53b4b7b Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sun, 30 Aug 2026 01:06:14 -0600 Subject: [PATCH 4/7] docs: fix tap examples to handle_info/2 and stale module/function names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README and several guides showed UI taps handled by handle_event("tap", %{"tag" => ...}) — a real tap delivers {:tap, tag} to handle_info/2, so those example screens would never respond on a device. README's diagram and testing snippet updated for the per-screen process model and Mob.ScreenCase. getting_started referenced Mob.Nav.push/2, which does not exist (Mob.Socket.push_screen is the API). event_audit's list-select re-emitter is Mob.Screen.Server since MOB-113. Co-Authored-By: Claude Fable 5 --- README.md | 20 ++++++++++++++------ guides/device_capabilities.md | 8 ++++---- guides/event_audit.md | 2 +- guides/getting_started.md | 10 +++++----- 4 files changed, 24 insertions(+), 16 deletions(-) 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/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 From ffbf7cd98cc32af42c3b1929fec6d084da6abea3 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sun, 30 Aug 2026 01:06:28 -0600 Subject: [PATCH 5/7] docs(agentic): split into single-agent and agent-team halves; new practices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part 1 (Working with one agent) keeps the existing content in order and adds: verify effects not exit codes (assert the app answers after a deploy), the honesty contract (success = a handler ran — assert on state change after a tap, settle-window caveat), match the evidence to the question (frames for layout, screenshots-with-tolerance for appearance, recordings for motion), lifecycle-event simulation recipes (simctl push .apns, adb broadcast / cmd notification post), and environment discipline (complete .tool-versions incl. zig/JDK, the MOB_DIR/MOB_DEV_DIR/MOB_NEW_DIR override chain). Part 2 (Working with agent teams) is new: one driver per device with lease discipline (humans outrank agents), unique node names per session (mob.connect --name), per-task git worktrees, the mob.push/mob.watch fan-out hazard (they reach every live node, no device scoping — fleets deploy per device or push over their own dist connection), and durable artifacts as the handoff medium between context windows. The standard agent loop gains a settle/2 step before native-side reads. Co-Authored-By: Claude Fable 5 --- guides/agentic_coding.md | 254 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 235 insertions(+), 19 deletions(-) diff --git a/guides/agentic_coding.md b/guides/agentic_coding.md index 95ea1ec7..e39f8508 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,133 @@ 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. + +One assumption to respect: effect detection is **process-wide**. You are +asserting "the state changed after my tap", and anything else driving the same +app inside that window — another agent, a timer, a device event — can +false-positive the check. Exactly one agent drives a given device at a time +(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 appearance** ("is it the right shade?", "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 as a bug in the test. +- **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 +397,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 +412,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 +444,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 +453,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 +467,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. From 53bd0e0c3939cb382d981b978ccd55f60479f54c Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sun, 30 Aug 2026 01:06:28 -0600 Subject: [PATCH 6/7] docs(mix): group new modules in hexdocs sidebar Mob.Router joins Navigation; Mob.Screen.Server, Mob.Listener and Mob.Sender get a Runtime Processes group; Mob.ScreenCase joins Testing & Debugging. All five were shipping ungrouped. Co-Authored-By: Claude Fable 5 --- mix.exs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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] ] From 6c19649c884d110b901ee9800df6ebad1fe362aa Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sun, 30 Aug 2026 01:14:48 -0600 Subject: [PATCH 7/7] docs: document #80's honest tap returns and pixel sampling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Written minutes before #80 merged, the honesty-contract and evidence-matching sections claimed no pixel-sampling API existed and leaned solely on state-change assertions. Now that tap_xy/3 reports observed effect, both guides document the contract: :ok only when an event reached the BEAM within 300ms, else {:error, :no_view_at_point | :no_element_at_point | :no_effect} — plus the platform limits that make :no_effect legitimate (SwiftUI on_tap containers, physical-device injection) and the serial-harness assumption the 300ms window shares with state-change checks. Evidence matching now splits exact-color decisions (sample_color/2: real pixels, dominant/average as 0xAARRGGBB, iOS debug-build only) from holistic visual parity (screenshots with tolerance). testing.md gains matching sections. No change needed for #77: push_notifications.md already described tap-to-open from a killed app, which that fix made true. Co-Authored-By: Claude Fable 5 --- guides/agentic_coding.md | 45 ++++++++++++++++++++++++++++++++-------- guides/testing.md | 27 ++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 9 deletions(-) diff --git a/guides/agentic_coding.md b/guides/agentic_coding.md index e39f8508..155ab702 100644 --- a/guides/agentic_coding.md +++ b/guides/agentic_coding.md @@ -284,11 +284,29 @@ 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. -One assumption to respect: effect detection is **process-wide**. You are -asserting "the state changed after my tap", and anything else driving the same -app inside that window — another agent, a timer, a device event — can -false-positive the check. Exactly one agent drives a given device at a time -(see [Working with agent teams](#working-with-agent-teams)). +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 @@ -301,10 +319,19 @@ one, not a screenshot of everything: 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 appearance** ("is it the right shade?", "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 as a bug in the test. +- **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. diff --git a/guides/testing.md b/guides/testing.md index 28affd64..48be16d9 100644 --- a/guides/testing.md +++ b/guides/testing.md @@ -230,6 +230,33 @@ 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`.