# Changelog

All notable changes to **mob** are documented here.

Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [SemVer](https://semver.org/spec/v2.0.0.html).

Full module documentation: [hexdocs.pm/mob](https://hexdocs.pm/mob).

---

## [Unreleased]

## [0.9.18] - 2026-10-14

### Added
- **`anchor: "bottom"` on `:scroll`, for chat logs (MOB-481).** The scroll
  opens at the end of its content. On iOS 18 and later, when content grows or
  the viewport shrinks while the view is at the end or within 32 pt of it, it
  moves to the new end; a user who has scrolled further up is left where they
  are. On iOS 17 it opens at the end and growth is left
  to SwiftUI's `defaultScrollAnchor(.bottom)`. Vertical, non-lazy scrolls
  only; `"top"` (the default) is unchanged. Android needs the matching mob_new
  template. See `guides/components.md`.

### Fixed
- **An adaptive theme now follows the OS light/dark flip (MOB-479).**
  `Mob.Theme.AdaptiveWatcher` decided whether to re-resolve by comparing the
  active theme with what the adaptive module returns *after* the OS had
  flipped, which never matched, so `Mob.Theme.Adaptive` (and any module
  registered with `register_adaptive/1`) stayed on the old scheme. The
  watcher now goes by what was passed to `Mob.Theme.set/1`: the registered
  module, or `{module, overrides}` (the overrides are kept), follows the OS;
  a struct, a keyword list or any other module is a fixed theme and is left
  alone. A plugin's suggested default font, applied at boot, no longer turns
  a module-set theme into a fixed one. See
  `decisions/2026-10-13-adaptive-theme-follows-set-source.md`.

## [0.9.17] - 2026-10-09

### Added
- **Async work in screens, the LiveView way.** `Mob.Socket.start_async/3` runs
  a function in a task linked to the screen and delivers `{:ok, result}` or
  `{:exit, reason}` to the new optional `handle_async/3` callback, so a screen
  can render a loading state (a spinner, or skeleton boxes) and fill it in
  when the data arrives. A crashing task does not crash the screen, and none of
  the task's messages (`:DOWN`, the link's `:EXIT`) reach `handle_info/2`.
  Starting a name that is still running replaces the older task and only the
  newest reports. `Mob.Socket.cancel_async/3` always stops the task; its
  reason is only what `handle_async/3` receives as `{:exit, reason}`. A
  screen's tasks stop when the screen does, including a
  stop with reason `:normal`. `Mob.ScreenCase.render_async/2` awaits them in
  off-device tests. Mirrors LiveView's `start_async/3`, `handle_async/3`,
  `cancel_async/3` and `render_async/2`. See `decisions/2026-10-09-screen-async-tasks.md`.
- **Docs for AI agents, in the package and on hexdocs.** The Hex package now
  ships `guides/` and a new `usage-rules.md`, so an app has
  `deps/mob/guides/*.md` and `deps/mob/usage-rules.md` matching its exact mob
  version (`mix usage_rules.sync` picks the rules file up). `usage-rules.md` is
  the short version: screens, async loading, lists, navigation, testing, and
  where the docs are. It is also on hexdocs as "Usage Rules for AI Agents".
  A new guide, [Coming from Phoenix LiveView](https://hexdocs.pm/mob/coming_from_liveview.html),
  maps `assign_async`, `start_async`, streams, `phx-click`, navigation and
  `LiveViewTest` to Mob. Guide titles name what they cover (`llms.txt` lists
  titles only), e.g. "Screen Lifecycle & Async Loading". The README has a "For
  AI agents" section, and `guides/agentic_coding.md` says where an app's agent
  finds the docs.

### Fixed
- **Docs: async loading and pagination examples.** The pull-to-refresh and
  infinite-scroll patterns in `guides/events.md` started a `Task.async/1` and
  never handled its reply, so `:loading`/`:refreshing` never cleared; they now
  use `start_async/3`. The infinite-scroll example used `on_end_reached` on a
  `<Scroll>` and matched `{:end_reached, tag}`. `on_end_reached` is a
  `<LazyList>` prop, and it delivers `{:tap, tag}`. `guides/screen_lifecycle.md`
  documents `handle_async/3` and that slow work in `mount/3` delays the screen's
  first frame.
- **Docs: broken and wrong links.** The `Mob` module doc linked
  `guides/getting_started.html` and `guides/architecture.html`, which 404 on
  hexdocs. `guides/agentic_coding.md` linked the repos' `AGENTS.md` on a
  `main` branch that doesn't exist (it's `master`).

## [0.9.16] - 2026-10-09

### Added
- **`MOB_NODE_HOST`: the host a physical iPhone's node is named after**
  (MOB-428). A device build names its node `<app>_ios@<WiFi IP>` whenever
  the phone has WiFi; when that WiFi is a network the Mac can't route to,
  the only path is the USB cable (the link-local `169.254.x.x` address), and
  the node, though its EPMD answers over the cable, could never be dialled.
  `mob_beam.m` now takes `MOB_NODE_HOST` when it is one of the phone's own
  IPv4 addresses (anything else is ignored) and otherwise keeps WiFi →
  link-local → loopback. mob_dev 0.7.18's `mix mob.connect` passes the
  address it reaches the phone at. The choice lives in
  `ios/mob_node_host.h`, covered by `test/native/node_host_test.c`. See
  `decisions/2026-10-09-ios-node-host-override.md`.

## [0.9.15] - 2026-10-08

### Added
- **Plugin self-test contract** (MOB-411). `Mob.Plugin.SelfTest` is a
  behaviour with one callback, `run(%{platform:, device:}) :: :pass |
  {:fail, reason} | {:skip, :needs_hardware | :needs_user | reason}`. A
  plugin names its implementation in the manifest as `selftest: Module`;
  `mix mob.selftest` (mob_dev 0.7.17) and mob_ci call it on the device
  over distribution for every activated plugin. A pass means the test got
  a real answer back from the plugin's native code, not that a module
  loaded. `Mob.Plugin.SelfTest.result?/1` says whether a term is a valid
  result. See `MOB_PLUGINS.md` ("Self-test") and
  `decisions/2026-10-08-plugin-self-test-contract.md`.

## [0.9.14] - 2026-10-08

### Added
- **Native views in a tree rendered by another process** (MOB-409).
  `Mob.Component.expand/3` marks each node it expands with the component
  that drew it (`__mob_expanded__: {owner, id, module, component_pid}`,
  beside `props`, never sent to the native side). A screen drawing a tree
  another process expanded passes such a node through, without starting the
  component again, once `Mob.ComponentRegistry` confirms the owner still
  runs that very component; an unconfirmed one (forged, or stopped or
  replaced since) is drawn as an empty column. `Mob.List.expand/3` now
  keeps a node's other keys. A process that renders a subtree for a screen
  (Operator's front host, which keeps untrusted screen code out of the
  shell) expands native views itself, owns their components
  (`Mob.ComponentRegistry.reconcile/2`, repaint on `{:component_changed,
  id, module}`), and should strip `:__mob_expanded__` nodes from untrusted
  renders first.

## [0.9.13] - 2026-10-07

### Added
- **App-written Erlang init arguments: `Mob.InitArgs` and
  `$MOB_DATA_DIR/mob_init_args`** (MOB-406). An app writes init arguments for
  its next launch (`Mob.InitArgs.write(["-proto_dist", "inet_tls",
  "-ssl_dist_optfile", path])`) and both native launchers append them after
  mob's own init arguments, in development **and release** builds. This is
  what a custom distribution transport (TLS, `-proto_dist`) needs: those are
  init arguments, which the emulator refuses in the emulator-flag section
  `mob_beam_flags` feeds (and only `mix mob.deploy` writes `mob_beam_flags`,
  next to the deployed BEAMs, so a release build doesn't get one).
  Limits, enforced by `write/1` and by the launchers (which keep whole
  arguments up to the limit, log, and drop the rest): 1023 bytes, 63
  arguments. On iOS, init arguments containing `-name`/`-sname` make the
  launcher leave out mob's own development dist flags; Android still starts
  distribution at runtime. Needs a native rebuild (`mix mob.deploy --native`).
  See `decisions/2026-10-06-app-init-args.md`.

### Fixed
- iOS text fields (UIKit-backed, SwiftUI `TextField` and `SecureField`)
  deliver their final `on_change`, then `on_submit` and `on_blur`, as one
  native transaction on Return, Done and focus loss. Every route is captured
  before the first event, so a repaint triggered by the final change can no
  longer drop the submit or blur. `on_change` is exactly-once: it no longer
  depends on the field still being focused, and programmatic value updates
  are not echoed back. Replacing a focused field no longer loses a
  still-marked IME composition.

### Docs
- `mob_speech` (speech-to-text) and `mob_whisper` (offline whisper.cpp
  engine) are listed in the packages guide, README and surface matrix;
  `Mob.Speech`'s docs point to them for listening.

## [0.9.12] - 2026-10-03

### Upgrading
- On Android, `on_press_in` / `on_press_out` and `Mob.Test`'s held press live
  in the app's generated `MobBridge.kt` / `beam_jni.c`: new apps get them from
  `mob_new` 0.6.5; existing apps port the bridge changes listed in mob_new's
  0.6.5 CHANGELOG. Without them the props are ignored on Android and
  `capabilities/1` reports the press NIFs false. iOS needs nothing.

### Added
- **`on_press_in` / `on_press_out` on any node** (MOB-380): `{:press_in, tag}`
  when a finger goes down on the node, `{:press_out, tag}` when it lifts or the
  touch is cancelled. On a node that declares both they are always paired,
  even across the re-render a press usually causes: native resolves both
  handles at touch-down and keeps press_out's routing until the lift. They
  observe without consuming, so `on_tap`,
  `on_long_press` and an ancestor's scrolling keep working on the same node,
  `button` included. iOS (SwiftUI) and Android (Compose; the observer lives in
  the generated bridge, so it needs `mob_new` 0.6.5 or newer). For
  hold-to-talk and anything else keyed on a held finger. The `on_tap` of a
  node that declares either prop goes through the new `mob_send_press_tap`,
  which accepts a handle from the render before when its slot still holds the
  same pid and tag, so the re-render a press causes no longer drops the tap.
  Plain taps stay generation-strict. iOS 17 uses a SwiftUI `DragGesture`
  fallback (no `UIGestureRecognizerRepresentable` there) that hasn't been run
  on an iOS 17 device: it may stop an enclosing ScrollView from scrolling when
  the drag starts on the node, and inside a ScrollView a quick tap's `tap` may
  arrive before its `press_in`. iOS 18+ and Android don't have either issue.
- **`Mob.Test` can hold a press** (MOB-380). `press_down_xy/4`,
  `press_move_xy/3`, `press_up_xy/3` and `hold_xy/4` put a real finger on an
  Android app's window and keep it there across calls, so a Compose
  `detectTapGestures` `onPress` sits in `tryAwaitRelease()` while the test
  reads state (`adb input swipe x y x y 3000` does not produce that on every
  device). A held press auto-cancels after `:max_hold_ms` (30 s); other
  synthetic gestures refuse while it is down. New NIFs
  `mob_nif:press_down_xy/3`, `press_move_xy/2`, `press_up_xy/2`, reported by
  `capabilities/1`; iOS answers `{:error, :not_supported}`. By-tag
  `press_in/2`, `press_out/2` and `hold/3` drive a screen on every platform.

## [0.9.11] - 2026-10-03

### Upgrading
- **Deep links need native files that call `mob_deliver_link`** (MOB-379).
  New projects from mob_new 0.6.4 have them; an existing app ports the
  `MainActivity` / `MobBridge.kt` / `beam_jni.c` / `SceneDelegate` changes
  from the "Deep links" section of `guides/device_capabilities.md`, sets
  `url_schemes` in `mob.exs` (mob_dev 0.7.12) and makes `MainActivity`
  `singleTask`, then rebuilds with `mix mob.deploy --native`. Apps that
  don't use deep links need no change.
- A test double passed as `nif:` to `Mob.Router.start_root/3` (or
  `Mob.Screen.start_root/3`) needs `take_launch_link/0` returning `:none`,
  beside `take_launch_notification/0`; the router now calls both at start.

### Added
- **Deep links: an app opened by a URL gets it on the BEAM** (MOB-379). A
  screen receives `handle_info({:link, %{url: url, source: source}}, socket)`
  (`Mob.Link`): `source` is `:launch` for the link that opened the app (held
  until the root screen has mounted, delivered once) and `:running` for one
  opened while the app runs. `Mob.Link.register/1` sends links to another
  process instead, and can be called before the root screen starts so that
  process also gets the launching link; `unregister/0` undoes it. Native code
  hands each URL over with `mob_deliver_link(const char *url)`, declared in
  both `mob_beam.h` files, and the router drains what arrived before it ran
  with the new NIF `take_launch_link/0`. The forwarding lives in app-owned
  files: mob_new 0.6.4's `MainActivity` (`ACTION_VIEW` intents, through
  `MobBridge.nativeDeliverLink` and `beam_jni.c`) and `SceneDelegate` (URL
  contexts); older apps port it from the "Deep links" section of
  `guides/device_capabilities.md`. The schemes come from
  `config :mob_dev, url_schemes: [...]` in `mob.exs` (mob_dev 0.7.12), which
  also requires the Android `MainActivity` to be `singleTask`. Needs a native
  rebuild. See `decisions/2026-10-03-deep-link-delivery.md`.

### Changed
- The native FIFO that keeps a notification for the router until it starts is
  now a queue type with one instance per kind (notifications, links), on both
  platforms: `ios/mob_stored_queue.h` (tested by `make -C test/native run`)
  and `android/jni/mob_stored_queue.zig` (`zig test`). Notification behaviour
  is unchanged, except that clearing the queue (`NULL` to
  `mob_set_launch_notification`) now holds the lock once on Android, as iOS
  already did, and a failed binary allocation stores the entry rather than
  sending an unchecked one.

## [0.9.10] - 2026-10-02

### Added
- **Every screen socket carries the window's size class** (MOB-204, mob side of
  MOB-165). `assigns.size_class` is `{horizontal, vertical}`, each `:compact`
  or `:regular`, set before `mount/3`: on iOS the window's
  `traitCollection`, on Android the activity's `Configuration` width/height in
  dp with Material's breakpoints (`:regular` from 600dp wide / 480dp tall).
  When it changes — rotation, iPad Split View / Slide Over / Stage Manager
  resizes, an Android multi-window resize (iPhone Duo's fold is expected to
  arrive the same way; unverified until the Xcode 27.1 simulator, MOB-205) —
  every live screen, including ones under the top of a stack and in parked
  tabs, gets the new assign and then
  `handle_info({:mob_size_class_changed, new}, socket)`. A screen with no
  clause for it is not crashed. New `Mob.SizeClass`, NIF
  `mob_nif:size_class/0`, and `Mob.ScreenCase.mount_screen/4`
  (`size_class:`) plus `change_size_class/2` to test layouts per class. Needs
  a native rebuild (`mix mob.deploy --native`); without one screens hold
  `{:compact, :regular}`. iPad apps also need `UIDeviceFamily` `[1, 2]` in
  `Info.plist` to stop being letterboxed (the `mob_new` template change,
  MOB-206). See `decisions/2026-10-01-size-class-in-socket-assigns.md` and the
  "Size class" section of `guides/screen_lifecycle.md`.
- **Tappable canvas** (MOB-224). `<Canvas on_tap={{self(), :board}}>`
  delivers `{:tap, :board}`, like any other tappable node. A canvas can have
  both `on_tap` and `on_drag`: on iOS they are one gesture on the canvas, so a
  stationary touch fires the tap (plus the zero-length drag iOS has always
  sent) and a moving finger fires only the drag. The canvas still owns its
  touches, so drawing inside a `:scroll` does not scroll the page and does not
  fire an ancestor's `on_tap` or swipe. Android needed no change (the generic
  tap modifier already reaches canvas, and its drag detector consumes the
  drag). iOS needs `mix mob.deploy --native` to pick this up.

### Docs
- **Decision record for fold-aware layouts (iPhone Duo)** (MOB-208):
  `decisions/2026-10-01-fold-aware-layouts.md`. It fixes the design for the
  rest of MOB-200 before the 27.1 work starts: layout keys on `:size_class`,
  never orientation; `:reserved_regions` stays separate from `:safe_area`;
  `<Arrangement>` wraps Apple's `ArrangementView`; the fold is honoured
  automatically at a non-scrolling root by default, with a
  `respect_fold: false` opt-out (the mechanism is provisional until it is
  checked on the 27.1 simulator); hinge events are for effects only; scene
  accessories are an optional companion tree with an explicit kind;
  multi-window is shared with iPad; the iOS 17
  floor stays, with Duo API behind `@available`. It is checked against
  three of Apple's Duo Tech Talks (111461, 111463, 111464), the documentation article and the HIG, and corrects several ticket
  claims (e.g. `UIWindowSceneActivationAction` is iOS 15, and the generic
  `sceneAccessory` API is iOS 27.0, not 27.1). `AGENTS.md` gains a "Device
  shapes" pointer, and `common_fixes.md` an entry on content painted into
  the crease.

### Fixed
- **`<Icon>` and `<NativeView>` are on the tag whitelists** (MOB-224). Both
  natives render them, but `~MOB` warned about them and
  `Mob.ScreenCase.assert_renderable/1` needed `extra: [:icon]`. (`<Canvas>`
  was already added in MOB-188.) `renderable_types/0` no longer special-cases
  `:native_view`.
- **`Mob.UI.canvas/1` keeps `:on_tap` and `:on_drag`** (MOB-224). It used to
  drop both, so a canvas built with the helper could not take input.

## [0.9.9] - 2026-10-01

### Upgrading
- **iOS users on mob 0.9.8 / mob_dev 0.7.7: update both (mob 0.9.9, mob_dev
  0.7.8) and redeploy with `mix mob.deploy --native`** (MOB-348). The fix
  needs this release's `mob_beam.m` in the app and the cookie file that
  mob_dev 0.7.8 writes into the beams dir; `--native` installs the first and,
  on a physical iPhone, is the deploy that writes the second (a phone that is
  only hot-loaded over dist gets no file). With only one of them updated,
  relaunches keep 0.9.8's behaviour: mob 0.9.9 with mob_dev 0.7.7 finds no
  file, and mob 0.9.8 with mob_dev 0.7.8 ignores it (see Changed for
  `mob_secret`).

### Fixed
- **iOS: an app relaunched outside mob_dev is reachable again** (MOB-348).
  Since MOB-49 iOS took its private dist cookie only from `MOB_DIST_COOKIE` in
  the launch environment, so tapping the icon, `xcrun simctl launch` or an
  agent-device relaunch started the node with a random cookie that nothing
  could connect with. Like Android, `mob_beam.m` now falls back to
  `$MOB_BEAMS_DIR/mob_dist_cookie`, which `mix mob.deploy` writes (mob_dev
  0.7.8 or later), before the random cookie. The system log says which one it
  used (`[MobBeam] dist cookie: …`), never the value. See
  `decisions/2026-10-01-ios-dist-cookie-file.md`. The troubleshooting guide's
  cookie advice now describes the file.

### Changed
- **iOS ignores the public `mob_secret` cookie even in `MOB_DIST_COOKIE`**
  (MOB-348), as Android's `Mob.Dist` already did, so
  `mix mob.connect --cookie mob_secret` no longer reaches an iOS app on this
  mob; it falls back to the cookie file or a random cookie. Use the private
  cookie `mix mob.connect` manages (no `--cookie`).

## [0.9.8] - 2026-10-01

### Upgrading
- **Update mob_dev to 0.7.7 together with mob** (MOB-49). An app built on mob
  0.9.8 accepts only the private per-app distribution cookie that mob_dev 0.7.7
  hands over; mob_dev 0.7.6 or older cannot `mix mob.connect` to it (or deploy,
  push or watch into it). Redeploy after updating (`mix mob.deploy`, with
  `--native` for iOS) to move an existing app off the public `mob_secret`
  cookie.
- **Notifications arrive as one map on both platforms** (MOB-315, MOB-316).
  `{:notification, notif}` keeps `id`, `title`, `body`, `source` and `data`
  and adds `presentation` (`:foreground` or `:tap`) and `action`; a screen
  that acts on taps should match `presentation: :tap`, since arrivals while
  the app is open come through the same message. Apps with their own
  `MobFirebaseService` (generated by mob_new 0.1.45–0.4.10) must add
  `"presentation": "foreground"` to the JSON they pass to
  `MobBridge.nativeDeliverNotification`, or every foreground push looks like a
  tap; see "Changed" below. Update mob_new to 0.6.3 for new projects.
- **Crash logs no longer carry your app's data** (MOB-310). A screen or
  component crash logs the exception's module and its stacktrace, but not
  the exception message, the callback's arguments or the screen's assigns.
  If you relied on the message in logcat or the iOS console to debug, add
  your own `Logger` call where the error is raised.

### Changed
- **Notifications say whether they arrived or were tapped, with the same shape
  on both platforms** (MOB-315, MOB-316). `{:notification, notif}` now carries
  `presentation: :foreground | :tap` and `action` (`"default"` for a tap on the
  notification, `nil` for an arrival) alongside `id`, `title`, `body`, `source`
  and `data`; see `Mob.Notification`. iOS sent the same message for a banner
  arriving and for a tap, always with `source: :local` and without title or
  body. Android sent the raw `{:mob_launch_notification, json}` to the
  registered screen, which dropped it. Both natives now hand one JSON envelope
  to the router, which decodes it and forwards it to the process registered
  through mob_notify, else to the current screen. iOS `data` drops `aps` and
  keeps nested values, booleans and floats. A payload that does not decode is
  logged and dropped. Delivery now needs a root screen (`Mob.Screen.start_root`,
  as every generated app has): on iOS a process registered through mob_notify
  in an app without one used to get notifications directly. Notifications that
  come in before the root screen starts are kept, up to 16, and delivered to
  it in order. See `decisions/2026-10-01-notification-delivery-envelope.md`.

  **Upgrade step for apps with their own `MobFirebaseService`** (mob_new
  0.1.45–0.4.10 generated one): its `onMessageReceived` hands a foreground push
  to `MobBridge.nativeDeliverNotification` without `presentation`, and a
  missing `presentation` means `:tap`, so a screen that navigates on taps
  would navigate on every push that arrives while the app is open. Add
  `"presentation": "foreground"` to that JSON before the call (both the
  `mob_notification_json` string and the object it builds); see the push
  notifications guide. mob cannot fix this on its side: the taps an older
  `MainActivity` hands over also lack the field and reach the same entry
  point, with the same `source`.

### Fixed
- **iOS simulator: a second mob app launched outside mob_dev no longer dies
  on a dist port collision** (MOB-139). Without `MOB_DIST_PORT` (tapping the
  icon, `xcrun simctl launch`, agent-device relaunches) every simulator app
  listened on 9101; simulators share the Mac's network, so the second one
  halted on `eaddrinuse` a second after start, with the error only in
  `Documents/beam_stdout.log`. A simulator app now derives its port from the
  app name and simulator UDID the way mob_dev does (9100–9899) and takes the
  first free one from there. A port that is taken anyway (`MOB_DIST_PORT`
  pointing at an occupied port, or 9101 on a device) now logs
  `[MobBeam] ERROR: Distribution can't start: port N … is already in use`,
  with how to find the holder, and shows it on the startup error screen
  instead of exiting silently. See
  `decisions/2026-10-01-ios-sim-dist-port-per-app.md`.
- **iOS: `:text_field` ignored the theme and the type props** (MOB-237).
  It drew the system `.roundedBorder` field (white, system font) on every
  theme. It now draws the node's `background`, `border_color` /
  `border_width`, `corner_radius` and `padding`, and honours `text_color`,
  `placeholder_color`, the new `caret_color` (default: follows the text
  colour), `font` / `text_size` / `font_weight` / `italic` /
  `letter_spacing`, `text_align`, `disabled` / `enabled: false`,
  `max_length`, `lines` and `caret: "end"`, with the same semantics as
  Android since mob_new#82. `caret_color` theme atoms now resolve like the
  other colour props.
- **iOS: a font named by face (`font: "Inter-Bold"`, the documented theme
  `heading` pattern) rendered at regular weight** when `font_weight` was not
  set: `resolvedFont` always applied `.weight(.regular)`. The named face is now
  kept unless `font_weight` asks for another weight (MOB-237). As on Android,
  a named face is one face: `font_weight: "regular"` does not turn
  `"Inter-Bold"` regular; name the regular face instead.
- **Android: fresh `mix mob.deploy --native --android` failed on every app
  generated before mob_new 0.5.1** (MOB-226, regression from MOB-180 in
  0.8.0). MOB-180 added `extern "c" fn open/write/close` to the
  post_mortem marker write path in `android/jni/mob_nif.zig`, and zig
  0.17-dev refuses to compile that form when the surrounding module was
  built without `link_libc = true`. MOB-196 fixed newly-generated apps by
  adding the flag in the template, but pre-existing apps kept a stale
  app-owned `build.zig` and hit a compile error at `mob_nif.zig:4374`
  that said nothing about mob. The three declarations now live in
  `android/jni/mob_zig.zig` as bare `pub extern fn` — the `"c"` library
  annotation is what trips zig's libc-dependency check; a bare `extern fn`
  names no library, and the runtime linkage against Bionic's `libc.so` is
  unchanged.
  Existing apps pick the fix up on their next native build, no template
  change or app edit needed. `link_libc = true` in mob_new 0.5.1's
  Android `build.zig.eex` becomes redundant but is not removed — costs
  nothing and covers a future addition that does need libc.
- **The first native theme probe no longer uses a `:global` lock** (MOB-311).
  `Mob.Theme` serialised its first `mob_nif` probe with `:global.trans/3`,
  which made concurrent first theme calls sleep through `:global`'s random
  back-off, and released on the node name captured at acquire time, so
  `Mob.Dist` starting distribution during a probe leaked the lock and every
  later probe blocked for as long as the probing process lived. Probes now
  run one at a time in a locally registered process, `Mob.Theme.NifProbe`.
- **A screen crash no longer writes the screen's data to the log** (MOB-310).
  A `FunctionClauseError`'s stack frame carries the event params and the whole
  socket, a `KeyError`'s message embeds the map it searched, and OTP's crash
  report printed the screen's state and its last message (a typed
  `{:change, tag, value}`); `Mob.Router` and `Mob.NativeLogger` wrote all of it
  to logcat or the iOS console, in release builds too. `Mob.Screen.Server` and
  `Mob.ComponentServer` now redact a callback's crash, and a returned
  `{:stop, reason, ...}`, before it becomes the exit reason: the exception's
  module and its stacktrace by arity, never its arguments, and no message
  except the ones mob writes itself. Their `format_status/1` shows assigns by
  key and of the last message only its tag and the names the screen's source
  defines, never a value. A dying screen or component empties its mailbox and
  dictionary, which proc_lib's crash report (`handle_sasl_reports: true`)
  reads directly. `terminate/2`, monitors and `start_root/3`'s
  `{:error, reason}` receive the redacted reason. See
  `decisions/2026-09-30-screen-crashes-are-redacted-at-the-source.md`.
- **iOS delivers the notification tap that launched the app** (MOB-178).
  `mob_init_ui()` installs mob's notification-center delegate (unless the app
  set its own), so it exists before `didFinishLaunching` returns, which is
  when iOS requires it to hand over the launching tap. Previously mob_notify
  installed it after the BEAM was up and the tap was lost. Delivered once, to
  the root screen after it mounts. Existing apps get this with the mob update.
- **Android delivers a warm notification tap when no screen registered
  through mob_notify**, from a `MainActivity` that hands every tap to
  `nativeDeliverNotification` (mob_new's template, from the release with the
  matching change). It used to be stored for the next cold start and lost.
  An app-owned `MainActivity` generated earlier calls `setLaunchNotification`
  from every `onCreate`, and from `onNewIntent` when nothing is registered.
  That still only stores the tap while the BEAM is not running yet (a cold
  launch, delivered once at boot) and drops it once it runs, as before. So
  while the process lives, relaunching from Recents or re-creating the
  activity, which replay the launching intent, does not repeat the tap; after
  the system kills the process, a relaunch that restores the task still
  replays it at boot, as before. Until it ports the new `MainActivity`, such an
  app also still loses a tap that re-creates a finished activity in a running
  process, and a warm tap with nothing registered.
- **`component_server_test.exs` failed under a full `mix test` run**
  (MOB-339). Its tests waited a fixed 100–500 ms for a component to report a
  change or exit. A component that stops with a crash reason (its screen's
  `:killed`, a raising `terminate/2`) makes OTP log a crash report, and
  formatting that report loads about a dozen modules on first use. Each load
  is a round trip to the code server, queued behind every other async test's
  loads: up to 1.8 s measured. The exits are now awaited with no deadline of
  their own (the `:DOWN` cannot arrive before the process is gone; ExUnit's
  per-test timeout still fails a component that never exits), and change
  notifications are checked behind a `render_props/1` call, which the
  component answers only after sending them.

### Security
- **Development nodes no longer accept the public `mob_secret` cookie**
  (MOB-49). Every dev build was an Erlang node anyone on the same network
  could run code in. iOS now takes a private per-app cookie from mob_dev at
  launch (`MOB_DIST_COOKIE`; a launch without it gets a random one), and
  Android's `Mob.Dist` reads it from `$MOB_BEAMS_DIR/mob_dist_cookie`, which
  mob_dev writes into the app's private storage. `cookie: :mob_secret` in
  `Mob.Dist.ensure_started/1` is ignored; a custom cookie still wins. Android
  and the iOS simulator also listen on loopback only (a physical iPhone still
  listens on every interface, MOB-323). Needs the mob_dev that manages the
  cookie; with an older one the node gets a random cookie and can't be
  attached. See
  `decisions/2026-09-30-private-dist-cookie-and-loopback-listeners.md`.

### Documentation
- **`MOB_PLUGINS.md` described a `mix mob.add_plugin` task that doesn't
  exist** (MOB-335). The install flow now ends with the real third step,
  `mix mob.plugin.trust <name>`, which records the plugin's signing key in
  `mob.exs` (official plugins are pre-trusted in apps generated by
  `mix mob.new`). The manifest's `:setup` key is documented as reserved, and
  `MOB_PLUGIN_SECURITY.md`'s walkthrough uses the real commands.

## [0.9.7] - 2026-09-30

### Fixed
- **A dead diagnostic store owner is restarted** (MOB-302). When a store
  owner died, `Mob.Diag.Heir` kept its tables intact but nothing ever started
  a new owner: `Mob.Diag.health/0` showed `owner: nil` indefinitely, and if
  the heir then died every row went with it (seven receipts on a Moto G,
  counted as a `reset`). The heir now starts one replacement per death, which
  takes the tables back without waiting for a write; an owner that dies in
  its own setup is not restarted in a loop. `Mob.Diag.Store` also carries a
  framework version, so the first write after a `mob` upgrade sets each store
  up once, reclaiming owners an older `mob` left dead. See
  `decisions/2026-09-30-heir-restarts-diagnostic-store-owners.md`.
- **Post-mortems from destructive OS drains survive until someone sees them**
  (MOB-303). Android's `ApplicationExitInfo` marker advances when an exit is
  drained and MetricKit hands a payload over once, so a boot killed before
  anyone attached (`mix mob.connect` restarts the app) lost the crash's exit
  record for good. `Mob.PostMortem.Journal` writes each drained entry to
  `mob_post_mortem_journal.etf` in `Mob.data_dir/0` before emitting it, and
  every sweep emits it again (once per boot) until that capsule was handed to
  a bus subscriber when emitted, or returned by `Mob.Defect.Bus.recent/1`.
  Subscribing later or listing `classes/1` does not count. At most 32 entries
  are kept. BEAM crash dumps are unchanged. See
  `decisions/2026-09-30-destructive-post-mortem-drains-are-journaled-until-observed.md`.
- **Remote bus and trace subscribers survive a dropped connection**
  (MOB-304). `Mob.Diag.Subscribers` pruned a subscriber on any `:DOWN`,
  including `:noconnection`, so a host shell subscribed over adb (which cannot
  dial the host back) was silently dropped when the connection blinked. Such a
  subscriber is now parked: nothing is sent to its node while it is away, and
  it is republished, filters intact, when the node reconnects. It is dropped
  if its pid died meanwhile or the node stays away past
  `:mob, :subscriber_park_ms` (10 minutes). Parked subscribers survive a
  registry restart. Events emitted while parked are not replayed; read
  `Mob.Defect.Bus.recent/1`. See
  `decisions/2026-09-30-remote-subscribers-park-on-disconnect.md`.
- **Real taps leave receipts and traces** (MOB-305). Native input reaches a
  screen as `{:tap, tag}` / `{:change, tag, value}` / … through
  `Mob.Listener` and `handle_info/2`, never through `Mob.Screen.dispatch/3` or
  `Mob.Event.dispatch/4`, so a tap that changed state produced no
  `Mob.Agent.Receipts` entry and `Mob.Event.Trace` saw nothing. Discrete
  native input (tap, list-row select, change, focus, blur, submit, select,
  dismiss, single-fire gestures) now gets a receipt around `handle_info/2`
  with the same observed stages as the event path; every native input,
  including display-rate streams, is traced by canonical address. A receipt
  names the input and never carries its payload. A `:change` is named by its
  value's type (`:toggle` for a boolean, `:text_field` for text). See
  `decisions/2026-09-30-native-input-is-an-action.md`.
- **Taps that hit a screen being restarted are no longer lost silently**
  (MOB-306). After a handler crash, native kept routing taps to the dead
  screen's pid until the replacement's tree committed, and they vanished.
  They are still not redirected (the UI they aimed at is gone), but each now
  gets an `:undeliverable` receipt (owner `:event_routing`), is counted in
  `Mob.Diag.health/0` as `listener.undeliverable`, and is logged once, all
  without its payload.

### Changed
- **`Mob.Diag.health/0`** gains `listener` (`process`, `undeliverable`),
  `subscribers.parked` per topic, and each store's `framework_vsn`.
- **`Mob.Test.select/3`** now sends the native list-row shape
  `{:tap, {:list, id, :select, index}}`, so it is receipted like a real row
  tap. A screen still receives `{:select, id, index}` in `handle_info/2`.
- **App messages shaped like native input** (for example
  `send(self(), {:tap, :refresh})`) now get receipts and traces too, the same
  as `Mob.Test.tap/2`.
- **`Mob.PostMortem.Android.sweep_with/1` and `Mob.PostMortem.IOS.sweep_with/1`**
  (`@doc false` test seams) are now `sweep_with/2`, taking the journal path.

## [0.9.6] - 2026-09-30

### Added
- **Project config reaches the device.** `Mob.App.start/0` now applies the
  `:mob_app_config` module mob_dev ships with every build (your
  `config/config.exs`, plus `config/runtime.exs` evaluated on the build
  machine), with `Application.put_all_env(config, persistent: true)`, right
  after installing the native logger and before anything reads the
  environment. Until now `Application.get_env/2` returned `nil` on device
  for everything but `compile_env`. `persistent: true` keeps the values when
  an application is loaded later (starting a plugin's application loads
  it). A missing module (older mob_dev, host tests) is a no-op; one that
  can't be applied is logged and boot continues. New `Mob.AppConfig.load/1`.
  Logger is already running by then, so `config :logger, level: …` is
  applied with `Logger.configure/1` (an invalid level is logged and
  skipped); settings Logger reads only at startup (`:default_handler`,
  `:default_formatter`, `:translators`, …) don't take effect.
  Logs `[mob] app config: loaded :mob_app_config (<app>: <n> keys, …)` or
  `[mob] app config: :mob_app_config could not be applied, continuing
  without it: …`. See `guides/getting_started.md` → "App configuration on
  device".
- **Plugin OTP applications are started on device.** A Mob app boots from
  its own BEAM entry, so no dependency application was ever started: a
  plugin's supervision tree and its deps (Req, Finch, …) were missing and
  `mob_deliver` failed its own boot. `Mob.Plugins.start/0` (called by
  `Mob.App.start/0` in place of `start_lifecycle/0`) now runs
  `Application.ensure_all_started/1` for each activated plugin before any
  plugin's `lifecycle.on_start`. The names come from the runtime manifest's
  `:plugins` key (`Mob.Plugins.names/0`), or from the `:plugin` tags when an
  older mob_dev didn't write it. A start that fails, crashes or takes more
  than 5 s is logged (`[mob] plugin :<name>: OTP application failed to
  start, continuing boot: …` / `… did not start within 5000ms, continuing
  boot`) and boot continues. A timed-out start is killed before boot moves
  on, so when its app later fails it can't roll back (stop) a dependency
  it had started that a later plugin now uses. See `guides/plugins.md` →
  "Boot order and configuration on device".
- **`{:reset, module}` verdict for `:before_navigate` hooks.** Replaces all
  navigation with `module` (every stack discarded, persisted screen state
  cleared, empty params, `:reset` transition), whatever the original
  action was, so BACK can't return to the screen underneath. For
  `mob_deliver`'s forced-update screen. A `{:redirect, module}` still keeps
  the original kind of navigation.
- **`Mob.Dist` on Android falls back to the deploy's node suffix and port.**
  A launch from the home screen carries no intent extras, so the node came
  back as `<app>_android` on 9100 and tooling lost it. When
  `MOB_NODE_SUFFIX` / `MOB_DIST_PORT` are unset or empty, each now comes
  from `$MOB_BEAMS_DIR/mob_dist` (`suffix=…` / `port=…`, written by
  `mix mob.deploy`). Explicit options and env still win; a missing or
  garbled file keeps today's defaults.
- **`Mob.Device` `:app` lifecycle events on Android.** Android never sent
  them: a subscriber got nothing across HOME and back, and
  `Mob.Device.foreground?/0` always answered `true`. New native entry
  `mob_send_app_lifecycle(const char *event)` (`mob_beam.h`) delivers the
  same atoms as iOS, as `{:mob_device, event}` and
  `{:mob_device_android, event}` (`Mob.Device.Android` subscribers), and
  drives `foreground?/0` (true from `:did_become_active` until
  `:will_resign_active`, as iOS's `.active`). Apps generated by mob_new
  0.6.2+ call it from `MainActivity`: `onResume` → `:did_become_active`,
  `onPause` → `:will_resign_active`, `onStop` → `:did_enter_background`,
  `onStart` after a stop → `:will_enter_foreground`, `onDestroy` while
  finishing → `:will_terminate`; a configuration change sends nothing.

  **Upgrading an existing app:** `MainActivity.kt` and `beam_jni.c` are
  app-owned, so add the hook yourself (until you do, nothing changes:
  no events, and `foreground?/0` stays `true`). Copy the
  `nativeNotifyAppLifecycle` extern, the `backgrounded` / `recreating`
  companion flags, `notifyAppLifecycle/1` and the `onStart` / `onResume` /
  `onPause` / `onStop` overrides plus the `isFinishing` line in `onDestroy`
  from mob_new's `priv/templates/mob.new/android/app/src/main/java/MainActivity.kt.eex`,
  and the `Java_<pkg>_MainActivity_nativeNotifyAppLifecycle` function from
  `.../jni/beam_jni.c.eex` (with your package in place of
  `<%= jni_package %>`).

### Changed
- **`:after_first_render` hooks receive the committed screen's module** as
  an extra last argument (`apply(m, f, args ++ [screen_module])`, as
  `:before_navigate` appends the destination), and
  **`Mob.Router.Hooks.rearm_first_render/0`** makes the next committed
  frame, from any screen, fire the hook again. A plugin that must see a
  frame from one particular screen rearms until it gets it; mob_deliver
  ends an update's probation only on a frame from the app's own root, not
  its "please update" screen. **Upgrade:** a hook registered as
  `{M, :f, args}` is now called with one more argument, so `M.f` needs the
  extra arity. `nil` is passed only while a hot code push has mixed old and
  new framework modules.

### Fixed
- **`:after_first_render` no longer fires before the root screen has
  rendered.** The router fired it right after casting its first paint, so a
  root screen whose `render/1` raised on every attempt was still reported as
  stable, and `mob_deliver` ended the probation of an update that crashed
  the app, leaving the device in a crash loop. It now fires from
  `Mob.Sender`, once per VM, after the first frame is committed to the
  native layer without raising. A root screen that keeps crashing in render
  never triggers it; one that recovers after a restart triggers it then.
- **A root screen that fails to start is logged.** `Mob.Screen.start_root/1`
  returning `{:error, _}` (mount returned an error or raised) left a black
  screen and nothing in the log, because init failures produce no crash log
  and generated apps ignored the result. It now logs
  `[mob] root screen <Module> failed to start; the app has no screen: <reason
  or formatted exception>` at error level.
- **exqlite's NIF loads when more than one `exqlite-*` is on the device.**
  The Android launcher symlinked `sqlite3_nif.so` into the first
  `exqlite-*` directory `readdir` returned, while the code server loads the
  highest version. After an OTP push left an older exqlite next to the
  app's (or a newer one next to it), the NIF failed with `dlopen failed:
  library ".../exqlite-<v>/priv/sqlite3_nif.so" not found` and anything
  using SQLite crashed at startup. Every `exqlite-*` directory now gets the
  symlink.
- **Hostname lookups work on Android after the first few seconds.**
  `Mob.DNS.configure_pure_beam/1` seeded nameservers, but `:inet_db` keeps
  watching `/etc/resolv.conf`, which Android doesn't have, and on a lookup
  every 5 s it applied the missing file as an empty nameserver list. From
  then on `:inet.gethostbyname/1` (and Req, Finch, Mint) got `:nxdomain`.
  When the watched file doesn't exist, `configure_pure_beam/1` now stops the
  watch before seeding and keeps the nameservers already configured. A
  resolv.conf that exists (iOS simulator, host) is still followed. iOS's
  `Mob.App.configure_ios_inet_db/0` seeds only a hosts entry, which the
  hosts-file re-read doesn't touch.
- **An app left with no live screen ends its process instead of staying
  blank.** When the current screen crashed past the restart limit with no
  other live screen (the router logged `The app has no live screen.`), or
  the root screen failed to start because its `mount/3` returned an error
  or raised (`[mob] root screen <Module> failed to start; …`), the BEAM kept
  running. The window stayed black, a relaunch from the launcher brought the
  same process back, and a plugin waiting for that launch to die
  (mob_deliver's probation rollback) never saw it. On a device the router
  now also logs `[mob] ending the app process so the next launch starts
  fresh (no live screen after <Module> could not be restarted)` or
  `… (root screen <Module> failed to start)`, flushes the logger and calls
  `System.halt(1)`, so the next launch is a cold boot (Android logs
  `Process <app> (pid N) has died` and `exited cleanly (1)`). A failed start
  doesn't halt while another router is live. Routers started with an
  injected `:nif` (host tests) don't halt; `Mob.Router.start_root/3` takes
  `on_no_live_screen:` (a 1-arity function called with the screen module)
  to replace the default.

  **Upgrade note:** a root screen that can never mount or render now makes
  the app exit within about a second of every launch instead of showing a
  black screen. The logcat/NSLog lines above say which screen. If you relied
  on attaching over dist to a blank app to debug a broken root screen, read
  the logged reason instead, or pass `on_no_live_screen: fn _ -> :ok end`
  to `Mob.Screen.start_root/3` while you debug.

## [0.9.5] - 2026-09-30

### Added
- **`Mob.Router.Hooks`** — runtime-registered plugin hooks on the router:
  `:before_navigate` (called before a push/reset mounts a screen; may
  `:ok`, `{:redirect, module}`, or `{:error, reason}` to refuse) and
  `:after_first_render` (once per VM, after the root screen's first
  paint). A crashing or malformed hook counts as a refusal. Used by
  `mob_deliver` for just-in-time screens, its update gate, and ending an
  update's probation. See `guides/plugins.md` → "Router hooks".
- **`Mob.Device.app_version/0`** — the running binary's store-visible
  version (`CFBundleShortVersionString` on iOS, `PackageInfo.versionName`
  on Android), or `nil` when unavailable. Lets update gates such as
  `mob_deliver`'s read the shipped version instead of a config value that
  can drift from it.
- **`Mob.Diag.health/0`** — whether the framework's own diagnostics are
  working. Per store (receipts, defect bus, invariants, post-mortem
  registry, render stats): the owner, who holds each table, writes `lost`,
  times the tables were `reset` after being lost, and store-specific counts.
  Plus the heir and the bus/trace subscriber lists. Counts and pids only;
  read-only. An empty `Receipts.recent/1` or `Bus.classes/1` now means
  something only once this says the store is healthy.
- **`Mob.Event.Trace.subscribe/2`** — subscribe a given pid, including one
  on another node. `:rpc.call(node, Mob.Event.Trace, :subscribe, [self(),
  nil])` traces to the calling shell.

### Fixed
- **`Mob.ScreenCase` no longer races over `Mob.State` in async tests.**
  Each test's setup checked whether the globally named `Mob.State` was
  running and started it if not. Two `async: true` screen-test modules
  could both see it missing, and the second start failed the test with
  `{:already_started, pid}` (intermittently, under full-suite load). A
  store one test had started was also stopped when that test ended,
  even if another test was still using it. Setup now checks out a store
  from `Mob.ScreenCase.StateOwner`: overlapping tests share one
  `Mob.State` on a throwaway data dir, and it is stopped (and
  `MOB_DATA_DIR` restored) when the last one finishes, so later tests,
  including `async: false` ones that start `Mob.State` themselves, never
  see it. See `decisions/2026-09-30-screen-case-shares-mob-state-through-an-owner.md`.
- **Diagnostic stores are ready when their API returns.** Receipts, the
  defect bus, invariants, the post-mortem registry and render stats each
  started a table owner on first use and returned as soon as the owner's
  name was registered, before its tables existed. Concurrent first calls
  raised `ArgumentError` (`Receipts.recent/1`, `Bus.classes/1`: 1,400 of
  1,600; `Registry.mark_seen/1`: 42) or returned `:undefined` from
  `Invariant.violation_count/0` (823 of 1,600), and `RenderStats` dropped
  frames silently. All five now share one owner, `Mob.Diag.Store`, which
  waits for its tables.
- **Recorded evidence survives its owner.** A store owner that died used to
  take every row with it, and the next call recreated the tables empty
  without a trace. Tables now pass to a heir and back, stay writable
  meanwhile, and a loss of both is counted as a `reset`.
- **Diagnostic writes never raise and never disappear unseen.**
  `Receipts.record/1`, `Bus.emit/1`, `Invariant.run/2`,
  `Registry.mark_seen/1` and the render-stats frame store count a failure
  as `lost` (`Mob.Diag.health/0`), repair a missing table, and return
  normally. A receipt that failed to build in a screen used to be dropped
  silently.
- **Reloading a store kept its rows but reset its counters.** New receipts
  were numbered below old ones, so `recent/1` stopped reporting the newest,
  the 256 bound failed and `dropped/0` went back to 0. Counters now carry
  over.
- **Post-mortem sweeps no longer mark an artifact seen when its emit
  fails.** It is forgotten again and counted, so a crash dump still on disk
  is reported by the next sweep.
- **`Mob.Event.Trace` survives the process that started it.** Its table
  belonged to whoever called `start/0`, so tracing started over `:rpc`, or
  from an IEx session that later raised, silently stopped. Tracers are now
  kept in a monitored registry, and there is no table to lose.
- **Subscribing to the defect bus over `:rpc`.** The agentic guide's
  `:rpc.call(node, Mob.Defect.Bus, :subscribe, [])` subscribed the
  short-lived process `:rpc` runs in, which received nothing. The guide
  now passes `self()`.

### Changed
- **`Mob.Defect.Bus` holds at most 256 defect classes.** Fingerprints
  are as stable as the `fingerprint_key` a caller supplies, so a key
  carrying per-occurrence data used to add a class per emit for the life
  of the app. The least recently seen class is evicted and counted
  (`class_evictions` in `Mob.Diag.health/0`).
- **Bus and trace subscribers are kept by `Mob.Diag.Subscribers`**, apart
  from any table owner, and survive its restart. Subscribers on other
  nodes are monitored too.
- **`Mob.Event.Trace.start/0` is deprecated.** Tracing needs no setup;
  `subscribe/0,1,2` is enough. `stop/0` now unsubscribes every tracer.
- **The per-store `Owner` modules are replaced by `Mob.Diag.Store`.** Code
  or tests that called `Mob.Agent.Receipts.Owner.reload/0` should call
  `Mob.Diag.Store.reload(Mob.Agent.Receipts)`. A hot push onto this version
  keeps what the older `mob` recorded (its rows, sequence numbers, eviction
  count and subscribers) until the app restarts.
  See `decisions/2026-09-30-diagnostic-stores-share-one-hardened-owner.md`.

## [0.9.4] - 2026-09-30

### Fixed
- **`Mob.DNS.resolve/1` now refreshes a host after a network change.**
  It seeded `:inet_db` with a bare `:inet_db.add_host(ip, [host])`, which
  *appends* the new address to the host's list. `:inet.getaddr/2`
  (and so Req / Finch / Mint) kept returning the first, stale address.
  For example, after moving from a Wi-Fi network whose DNS sinkholed
  a host to mobile data, requests kept going to the sinkhole until
  the app restarted. Re-resolving now replaces the host's previous
  address.
- **Hosts that resolve to the same address no longer evict each
  other.** `:inet_db` keys runtime entries by address, so seeding
  `b` onto an address already holding `a` silently unseeded `a`, even
  though `resolve/1` had returned `{:ok, _}` for it. This happened
  with shared CDN addresses and every name a sinkhole answers with
  `0.0.0.0`. Concurrent `resolve/1` calls are serialised so they
  can't drop each other's names either.

If you worked around the stale address with `:inet_db.del_host(ip)`,
remove the workaround. `del_host` drops the address for *every*
hostname seeded under it. Upgrading and calling `resolve/1` again is
enough. See `decisions/2026-09-30-dns-resolve-replaces-host-entries.md`.

### Changed
- **`guides/dns_on_ios.md`, `Mob.DNS` / `Mob.App` docs, README** — whether physical
  Android can use BEAM's built-in DNS path depends on the device (a
  Moto G Power 2021 on Android 11 resolves, a Moto G Power 5G 2024 on
  Android 14 doesn't), and the iOS simulator needs the same setup as
  the device. The guide used to say both were unaffected. `resolve/1`
  remains the recommendation on Android.

## [0.9.3] - 2026-09-19

Docs-only refresh — no code changes vs 0.9.1. (0.9.2 was cut and
aborted mid-workflow when the mob_nx → mob_nx_eigen rename in
`packages.md` was found missing from the tarball; the retry as 0.9.3
includes it.)

### Changed
- **`guides/packages.md`** — updated the first-party plugin catalog
  for the current ecosystem shape:
  - New `mob_wake` row under **Messaging + wake** (Hex 0.1.0), now
    that the epic's iOS + Android silent-push receive paths are
    verified end-to-end (MOB-268 + MOB-271).
  - `mob_notify` row reframed as the device-side end of the
    server/device push wire, pointing at `mob_push`.
  - `mob_background` row sharpens the "not the same as `mob_wake`"
    distinction (continuous keep-alive vs episodic OS-fired handler).
  - `mob_nx` renamed to `mob_nx_eigen` (the actual Hex-published
    name; `mob_nx_vulkan` / `mob_nx_mlx` / `mob_nx_tflite` planned).
- **`guides/push_notifications.md`** — new "Silent pushes and
  OS-triggered handlers" section pointing at `mob_wake` for silent
  APNs / FCM data messages, with the `wake_payload`/`register` pair.
  Expanded Further Reading with `mob_wake` + `mob_background`.
- **`guides/background_execution.md`** — new paragraph in the
  default-model section covering the silent-wake path via `mob_wake`;
  rebuilt the "Choosing a pattern" table with dedicated rows for
  silent-push handlers and opportunistic scheduler handlers so it
  stops implying push notifications are the only OS-driven wake path.

## [0.9.1] - 2026-09-18

### Added
- **`text_content_type` prop on `:text_field`** — iOS wires it to
  `UITextContentType` so QuickType autofills SMS-delivered one-time
  codes above the keyboard. `text_content_type: :one_time_code` is
  the flagship value; `:username`, `:password`, `:email_address`,
  `:telephone_number` are mapped too. Empty string / unknown value
  = nil (no hint, default iOS heuristic). Pairs with mob_sms's
  Android SMS Retriever for a cross-platform OTP flow. Android
  side has no change here — Android delivers the code
  programmatically via a broadcast receiver, not via keyboard
  autofill.

## [0.9.0] - 2026-09-17

### Added
- **`~MOB` sigil recognises tags declared by installed plugins' manifests**
  (MOB-247). `Mob.Sigil` now unions plugin-manifest `:tags` and
  `ui_components[].tag` into the tag whitelist at compile time, so a
  plugin like `mob_mishka` can ship its own `<MishkaHueSlider>` without
  the user adding a `config :mob, :extra_tags` block. Per-process cache
  keeps compile-time overhead flat. `deps/mob/priv/tags/*.txt` remains a
  supported source but is no longer required for tags shipped by plugins.
  See `decisions/2026-09-15-plugin-manifest-tag-discovery.md`.

### Fixed
- **iOS Row and Column now honor `gap`, and direct Row labels resist flexible
  Spacer compression** (MOB-234). The native prop table now carries `gap` into
  eager and lazy SwiftUI stacks instead of hard-coding zero spacing. An
  unweighted text node directly beside a flexible Spacer receives horizontal
  layout priority, so the Spacer gives up slack before an otherwise-fitting
  label wraps.

### Docs
- **The component guide names the cross-platform scrolling-body + pinned-footer
  recipe and documents fixed dimensions winning over same-axis fills on both
  renderers** (MOB-233).
- **`guides/agentic_coding.md` covers the 0.8 agent surface** (MOB-212): what
  an action did (`Mob.Agent.Receipts`), the framework's own checks
  (`Mob.Invariant`), defects as data (`Mob.Defect.Bus`), post-mortems
  (`Mob.PostMortem.sweep/0`), the cross-platform differential
  (`Mob.Differential`, `MobDev.Differential.run/3`), render timing
  (`Mob.RenderStats`), and the mob_dev verification tasks (`mix mob.attest`,
  `mix mob.deploy --json`, `mix mob.mutate`, `mix mob.flake`). The standard
  loop now attests after a push and reads the receipt when state did not
  change. The `Mob.Test` platform matrix no longer says `view_tree/1` is
  `not_loaded` on Android: it works in apps generated by mob_new 0.4.33 or
  newer (MOB-157).

## [0.8.4] - 2026-09-12

### Fixed
- **Physical iOS: every launch crashed before the BEAM printed a line**
  (MOB-199, regression from MOB-166 in 0.8.0). The generated SceneDelegate
  calls `mob_notify_window_connected()` before `mob_boot_runtime()` on first
  launch, and that notifier called `enif_alloc_env()` unconditionally; before
  erl_start has initialised erts that jumps through a null allocator pointer,
  which iOS reports as `EXC_BAD_ACCESS` at `0x0` with a `CODESIGNING`
  "Invalid Page" termination and kills the app. The simulator happened not to
  fault, and `mix mob.deploy` reported "Apps restarted" over a dead process.
  `mob_notify_window_connected`, `mob_notify_color_scheme` and
  `mob_handle_back` (the boot-splash edge swipe) now return early until
  `nif_load` has run (`mob_runtime_up/0`), and again after `erl_start`
  returns. Affects apps generated by mob_new 0.4.33 and later against mob
  0.8.0+; they pick the fix up on their next native build, no template
  change needed.

## [0.8.3] - 2026-09-12

### Added
- **`config :mob, :extra_tags` — app-declared `~MOB` tags** (MOB-188). A
  composite a UI kit registers at boot (`Mob.Composite.register/2`) was
  invisible to the sigil's compile-time whitelist, so every `<MishkaChip />`
  warned "not in the Mob tag whitelist" and the only silence was rewriting
  `deps/mob/priv/tags` (which `mix deps.get` undid). The sigil now also accepts
  tags listed under `config :mob, :extra_tags` (PascalCase strings or
  snake_case atoms), read from the calling app's config at its compile time.
  Companion to mob_new #68, which lists the vendored Mishka Chelekom
  catalog's tags in a generated app's `config.exs`.
- **`:anchored` node type on iOS** (MOB-190). Children `[anchor, panel]`: the
  anchor renders in flow, the panel floats over the page at the root ZStack
  (anchor preference + root overlay, so no `corner_radius` Box or Scroll on
  the way up can clip it), placed by a verbatim port of the Android bridge's
  `MobAnchoredPositionProvider` — `side` / `align` / `side_offset` /
  `align_offset` / `panel_offset_*` / `flip` / `clamp` / `edge_padding` /
  `panel_max_*`; `on_tap` on the node is the outside-tap dismiss request.
  Before this `mob_nif.m` had no mapping for the type, so it fell to the
  zero-initialised `MobNodeTypeColumn` and every Mishka Chelekom popover,
  tooltip, menu, select and combobox stacked inline instead of floating.
  `Anchored` joins `priv/tags/ios.txt`; the Android half is the mob_new
  bridge template (mob_new #69, MOB-189). Documented in
  `guides/components.md`.

### Fixed
- **`Canvas` added to the tag whitelist** on both platforms. `Mob.UI.canvas/1`
  and both renderers have handled `:canvas` for some time, but the whitelist
  never listed it, so `~MOB(<Canvas />)` warned and
  `Mob.ScreenCase.assert_renderable/2` needed `extra: [:canvas]` on every
  screen that draws.
- **iOS: Column and Row honor `width` / `height`, and fixed beats `fill_*`
  and `layout_weight` on the pinned axis** (MOB-181, #158). The iOS renderer
  ignored `fixed_width` / `fixed_height` on Column and Row entirely (Box was
  fixed in #104); they now apply, and when both a fixed size and `fill_*`
  are set the fixed size wins on iOS, matching the Box rule. Android is
  unchanged and still lets fill win in that corner — see
  `decisions/2026-09-11-column-row-fixed-dims-precedence.md`.
- **iOS: `Mob.Audio.start_recording/2` now honors `:format` and
  `:quality`** (MOB-52). The NIF received the encoded opts as a JSON
  binary but silently discarded them — every recording was AAC/m4a at
  medium quality regardless of what the caller asked for.
  `nif_audio_start_recording` now parses the opts, chooses
  `kAudioFormatMPEG4AAC`/`AVEncoderAudioQualityKey` for `:aac` and
  `kAudioFormatLinearPCM` + `AVLinearPCMBitDepthKey` (8/16/24 bit per
  quality) for `:wav`, and writes to a `.wav` / `.m4a` temp file to
  match. `Mob.Audio.recording_opts/1`'s existing wire shape is
  preserved.


### Docs
- **`guides/components.md` — document `width` / `height` on `:column`
  and `:row`** (follow-up to MOB-181 / PR #158). The attributes exist
  and the native side maps them to `node.fixedWidth` /
  `node.fixedHeight`, but they weren't listed in the Column and Row prop
  tables (only `<Box>` and `<Image>` had them). Follow-up caller
  guidance for the iOS precedence rule (fixed beats fill on the pinned
  axis, including a `weight`-flexed child) added inline.
- **`guides/device_capabilities.md` — Clipboard and Share sections rewritten
  to the real API** (MOB-47, MOB-48). The guide called
  `Mob.Clipboard.write/2` and `Mob.Clipboard.read/1` (undefined; the real
  API is `put/2` and `get/1`, and `get/1` is synchronous — no
  `{:clipboard, :read, text}` message ever arrives). The Share section
  called `Mob.Share.sheet(socket, text:, url:, title:)` with options that
  don't exist; the real API is `Mob.Share.text(socket, binary)`.

## [0.8.2] - 2026-09-11

### Added
- **Post-mortem collection — `Mob.PostMortem` +
  `Mob.PostMortem.BeamCrashDump`** (MOB-158, phase 1). The framework
  now picks up the crash dumps the BEAM leaves behind on disk and turns
  each into a `Mob.Defect.Capsule` on `Mob.Defect.Bus` (kind:
  `:beam_crash`, owner: `:mob`).

  `Mob.PostMortem.sweep/0` and `Mob.PostMortem.sweep/1` do a one-shot
  scan of default and caller-supplied paths. Nothing runs
  automatically — an app opts in from its `on_start`; a developer or CI
  can call it from IEx. Same discipline the rest of `Mob.Defect`
  follows: mob owns the format and the bus, never becomes the
  collector.

  `Mob.PostMortem.BeamCrashDump` scans for `erl_crash.dump` files,
  reads a bounded 8 KB header (slogan / system version / taints /
  atoms / dump version), computes a sha256 for artifact identity, and
  never touches the dump body. Dumps stay on disk for offline
  inspection with `crashdump_viewer`. `Mob.PostMortem.Registry`
  records emitted ids in a public ETS table so a re-sweep is a no-op;
  registry state is intentionally not persisted across BEAM restarts,
  because a fresh BEAM sweeps back into a fresh bus.

  Fingerprint is the *normalized* slogan: binary literals (including
  ones truncated by the 8 KB header cut-off) and long numeric runs
  that are the printable form of the same are stripped, so two
  `{badarg, ...}` crashes on `io:put_chars/2` with different embedded
  payloads share a triage row instead of each opening one.

  Severity is picked from the slogan: allocator panics
  (`eheap_alloc`, `binary_alloc`, `ets_alloc`, `sl_alloc`,
  `driver_alloc`, `fix_alloc`, `std_alloc`) and boot-time crashes
  (`Kernel pid terminated`, `Runtime terminating during boot`) are
  `:fatal`; a literal `normal` exit is `:info`; anything else is
  `:critical`.

- **iOS MetricKit ingest for `Mob.PostMortem.IOS`** (MOB-179). Replaces
  the phase 1 scaffold. `Mob.PostMortem.sweep/0` on iOS now attaches an
  `MXMetricManagerSubscriber` on first call (lazy — no work for apps
  that never opt in), buffers OS-delivered payloads in a bounded native
  queue (32 entries, oldest-dropped on overflow), and drains them into
  `Mob.Defect.Capsule`s on each subsequent call. Ships in release —
  MetricKit is the whole reason MOB-158 exists.

  Payload → kind mapping: `MXCrashDiagnostic` → `:native_crash` /
  `:fatal`; `MXHangDiagnostic` → `:anr` / `:critical`;
  `MXCPUExceptionDiagnostic` and `MXDiskWriteExceptionDiagnostic` →
  `:perf_regression` / `:warning`. (These are the individual
  diagnostics; the `MXDiagnosticPayload` container that carries them
  is what MetricKit hands the delegate.)

  Fingerprint groups a crash by (kind + top-frame binary + top-frame
  offset) so the same crash across launches becomes one triage row.
  Redaction: MetricKit call-stack payloads carry only mangled symbols,
  binary UUIDs and image names — safe identifiers, no user data. The
  full `MXDiagnosticPayload.JSONRepresentation` rides on evidence,
  bounded by the capsule's existing truncation.

  iOS 14+ required (`didReceiveDiagnosticPayloads:` is iOS 14+). Older
  builds hit the fallback path and return `[]`. Android's stub NIF also
  returns `[]` — `ApplicationExitInfo` is a separate ticket (MOB-180).

- **Android ApplicationExitInfo ingest for `Mob.PostMortem.Android`**
  (MOB-180). Replaces the phase 1 scaffold. `Mob.PostMortem.sweep/0`
  on Android now pulls the OS-held history of process exits via
  `ActivityManager.getHistoricalProcessExitReasons` (API 30+), filters
  against a persistent marker at
  `<filesDir>/mob_post_mortem_appexit_marker.txt` so each exit emits
  exactly once across boots, and turns each new entry into a
  `Mob.Defect.Capsule` on `Mob.Defect.Bus`.

  Reason code → kind mapping: `REASON_CRASH` / `REASON_CRASH_NATIVE`
  / `REASON_SIGNALED` → `:native_crash` / `:fatal`; `REASON_ANR` →
  `:anr` / `:critical`; `REASON_LOW_MEMORY` /
  `REASON_EXCESSIVE_RESOURCE_USAGE` → `:oom` / `:fatal`;
  `REASON_USER_STOPPED` / `REASON_USER_REQUESTED` / `REASON_EXIT_SELF`
  / `REASON_DEPENDENCY_DIED` / `REASON_OTHER` / `REASON_FREEZER` →
  `:user_kill` / `:info`.

  Fingerprint groups by `(kind + process_name + reason_code)` — the
  same class of exit for the same process across boots becomes one
  triage row. The emitted capsule carries reason code, pid,
  timestamp, process name, and the OS-generated description string.
  Trace file contents (an ANR's stack text) are NOT included this
  phase — they can carry app strings and need the same discipline the
  receipt module documents before they can safely reach the bus.

  Implementation is pure Zig JNI in `android/jni/mob_nif.zig` rather
  than a `MobBridge.kt.eex` template addition. Every existing mob
  app gets the feature the moment they bump mob, with no template
  refresh dance. See
  `decisions/2026-09-11-appexit-native-jni-not-bridge.md`. iOS
  registers a stub `nif_post_mortem_android_drain` returning `[]` so
  the shared `mob_nif.erl` NIF list resolves on both platforms.

---

## [0.8.1] - 2026-09-11

### Added
- **Defect bus — `Mob.Defect`, `Mob.Defect.Capsule`, `Mob.Defect.Bus`,
  `Mob.Defect.Sinks.Dev`** (MOB-159, phase 1). Capture + dedup + attribution
  for defects the framework detects — steps 1-3 of the incident-to-draft-PR
  loop. Not the loop itself; no auto-action, no auto-PR.

  `Mob.Defect.Capsule` is the `mob.defect/1` schema from
  `decisions/2026-09-04-defect-reports-are-a-shipped-feature.md` in Elixir:
  `Capsule.new/1` builds the struct, populates `build` and `device`
  automatically, tags `redaction: :applied` on the caller-trust contract,
  and truncates evidence at 4096 bytes per string / 64 elements per list /
  8 levels of depth so a malicious deep term cannot exhaust the packager.
  `fingerprint/3` is a sha256 over `kind + owner + fingerprint_key` only —
  time, id, build and device are excluded so a defect groups across
  releases and devices. `to_json/1` produces the wire form; the whole
  capsule round-trips through Jason.

  `Mob.Defect.Bus` is an in-process pub/sub with two ETS tables (a
  `classes` set keyed by fingerprint that carries the first capsule and an
  occurrences counter, and a bounded ring of the 64 most-recent capsules).
  `emit/1` is on the write path — no GenServer round-trip, mirroring
  `Mob.Agent.Receipts.record/1`. Subscribers are stored in
  `:persistent_term` and monitored by the owner GenServer so a subscriber
  exit prunes itself; a dead pid in the fanout list is a no-op because
  `send/2` does not raise on a dead pid.

  `Mob.Defect.Sinks.Dev` is an opt-in GenServer that subscribes on
  `init/1` and Logger-writes each capsule at a severity-driven level
  (`:fatal` / `:critical` → `error`, `:warning` → `warning`, else `info`).
  Not started by default — per the decision record, mob owns the format
  and the bus but never the destination.

  `Mob.Defect.emit_invariant_violation/1` and `emit_divergence/2`
  encapsulate the mapping from a detector's native shape (`Mob.Invariant.Violation`,
  `Mob.Differential`'s divergence map) to a capsule with the right owner,
  kind, and fingerprint key.

  Wired: the invariant registry's record path now emits a capsule for
  every confirmed violation. Not wired in this release: the differential
  emit-point, a follow-up mob_dev change that lands after this ships to
  Hex.

  See `decisions/2026-09-11-fingerprint-and-evidence-are-separate.md` for
  the fingerprint-vs-evidence design.

---

## [0.8.0] - 2026-09-11

### Added
- **`Mob.Differential.compare/3`** (MOB-157). A pure comparator over two
  `Mob.Test.view_tree/1` snapshots — pass an iOS and an Android tree, get back
  `:ok` or `{:divergence, %{path, reason, ios, android}}` naming the first node
  that differs. Depth-first, left-to-right, short-circuit: a parent's own
  divergence is reported before its children's; among children the leftmost
  wins.

  Compares **structure** (`type`, child count), **label**, and **value** at
  every node. Compares **frames** only when both sides carry one, with a
  configurable dp tolerance — Android's `MobBridge.uiViewTree()` reports frame
  only for nodes with `props["id"]`, so requiring geometry on every node would
  flag every non-id'd node as a divergence; fixture authors choose which nodes
  need geometry compared. Root frame is not compared (it is the screen size).
  `class`, `bg_color`, `text_color` are not compared yet — both are `null` on
  Android today, and a partial answer would misattribute divergence.

  Returns `{:error, :not_ready}` when either side is `nil`, `{:error, _}` or
  `:no_window`, so a harness that samples too early does not file its own gap
  as a framework defect.

  Deliberately pure and stateless: rendering the same fixture on both devices
  and feeding the two trees to this belongs with `mob_dev`, in its own change.
  Every rule has a reversion-bar test verified by mutating that rule and
  confirming the suite flips, including the tolerance boundary and the
  asymmetric nil-frame cases that a previous version of the test suite failed
  to distinguish. A schema-drift guard asserts the eight-key contract the
  comparator assumes, so a new field lands with an intentional decision to
  compare or skip rather than as silent divergence. See
  `decisions/2026-09-10-differential-detector-is-a-pure-comparator.md`.

- **Runtime invariant registry — `Mob.Invariant`** (MOB-156). Checks the
  framework can make about itself, with the rule that keeps them from becoming
  noise: **a violation is recorded only if the same violation is still there at
  the next sampling of that point, and is at least 50ms old**. Every check reads
  live state from concurrently changing processes, so a screen mid-teardown looks
  exactly like a leak.

  Re-running the check immediately instead was measured filtering none of them —
  two evaluations a microsecond apart cannot disagree — and reported a confirmed
  `:critical` on 60 of 60 healthy teardowns. Deferring to the next sample, with
  an age floor, gives 0 of 60 while still catching an injected regression in the
  reaping path it guards.

  `register/2` takes a sampling point (`:on_screen_stop`, `:periodic`,
  `:after_committed_frame`), a severity and a check function. A check that raises
  is reported rather than propagated — a diagnostic must never affect what it
  observes.

  Two built-ins ship. `orphaned_component` — a live component under a dead owning
  screen, the leak class three of four agents in MOB-149 named independently —
  runs at `:on_screen_stop`, which is wired. `dead_screen_in_nav` is registered
  for `:periodic`, and **nothing drives `:periodic` yet**; it is reachable only
  by an explicit call until the defect bus lands in MOB-159. The other eight
  checks MOB-156 names are listed in `Builtins.unimplemented/0` with what each
  needs, and a test asserts none is ever silently registered.

  Measured through `run/2` at 1.6µs with an empty registry, 7µs at 100 live
  components and 16µs at 100 orphaned ones, once per screen teardown rather than
  per frame; `cost_us/2` re-measures on the device that matters. See
  `decisions/2026-09-10-an-invariant-must-survive-to-the-next-sample.md`.

- **Causal receipts — `Mob.Agent.Receipt`** (MOB-155). Every dispatched event now
  gets an `action_id`, and the screen records which stages the action
  reached: dispatched, handled (or unhandled), assigns changed, navigation
  requested, frame changed, committed. The first stage it fails to reach names the layer answerable for it,
  so "the tap did nothing" becomes "the handler ran and changed `:count`, and the
  tree did not change" — which points at a `render/1` that never reads `:count`.

  The stages are *observed*, not reported: only `handled` is proved by the
  callback, and every later stage is a before/after comparison the screen makes
  itself, so a handler cannot claim an effect it did not have.

  A navigation is its own verdict, and explicitly a *request*: this screen does
  not paint when the handler navigates, so deriving the answer from the absence
  of a paint would report a screen push as "the handler did nothing" — but the
  router may also refuse the request (a pop at the root), which this screen
  cannot see, so the owner is `:unknown` rather than "nothing to answer for".

  **Receipts carry no state read out of assigns.** They do carry the event tag,
  which is the action's identity and whatever the render tree put in `on_tap`.
  A crash is reduced to its kind,
  exception module and top stack frame; the message is dropped unless the
  framework built it, because `KeyError` and friends embed the term that failed
  and would otherwise carry the whole assigns map into telemetry.
  `Mob.Agent.Receipts.fetch/1` retrieves one by id; `recent/1` lists the newest.
  Bounded at 256, with `count/0` and `dropped/0` so a missing receipt can be told
  apart from an id that never existed.

  Emits `[:mob, :action, :stop]` **only when the host app already has
  `:telemetry` loaded** — `mob` keeps its single runtime dependency.

  `native_commit` is `:unknown`: this says what the BEAM did, not that the pixels
  changed. See
  `decisions/2026-09-10-a-receipt-per-action-not-a-window.md`.

### Fixed
- **A safe-area reading taken before iOS had a window is no longer kept for the
  life of the screen** (MOB-166). `nif_safe_area` returned zeros when it could
  find no window, which is indistinguishable from a device that genuinely has no
  insets, and `ensure_safe_area/3` stopped asking once the assign existed. A
  screen that painted before the window existed was under-padded at the bottom
  and sides until it was replaced.

  Two ordinary launches reach a paint that early: a background launch connects no
  window scene at launch, and an iOS 15+ prewarmed launch runs
  `didFinishLaunchingWithOptions:` long before the user taps the icon.

  The NIF now answers `:no_window` — on a genuine absence and on a timeout, both
  of which mean "not an answer". Zeros are still assigned (screens read
  `assigns.safe_area` directly, so a missing key would be a `KeyError` in
  `render/1`) but marked unconfirmed and re-read on the next paint.

  Re-reading on paint is not enough on its own, because nothing repaints when a
  scene connects. New `mob_notify_window_connected()`, called from
  `scene:willConnectToSession:`, sends `{:mob_window, :connected}`; the screen
  invalidates its cached insets and repaints. That also covers insets changing
  under a live screen — a rotation, a resized scene — which a confirmed-once
  cache would otherwise never pick up.

  **Requires a native rebuild** (`mix mob.deploy --native`), and the two halves
  must move together. Old native with new Elixir degrades safely: a 4-tuple is
  still handled. **New native with old Elixir crashes** — `{t, r, b, l} =
  :no_window` raises in `Mob.Screen.Server.init/1` and the root screen never
  starts — so do not point `mob.exs`'s `mob_dir` at a newer checkout than the
  `mix.exs` dependency.

### Added
- **`mix mob.flake`** — run the suite repeatedly and report which tests are not
  deterministic. `--runs N`, `--until-failure`, `--keep-going`, `--seed`, and a
  path to narrow the target. A flake does not announce itself; it fails once on
  someone else's branch and the natural response is to re-run and move on. This
  makes looking cheap and deliberate.

### Fixed
- **NIFs that wait on the UI thread no longer block the only scheduler**
  (MOB-164). Android runs the BEAM with `-S 1:1` — one normal scheduler — so a
  NIF that waits there stops every process on the device. `screen_info`,
  `scroll_to`, `safe_area`, `clipboard_get` and `webview_can_go_back` all did,
  and thirteen more `dispatch_sync` on iOS. All now run on a dirty IO
  scheduler.

  Three of the Android waits had **no timeout at all**, which is not a stall
  but a hang: if the main thread never answered, no Erlang process on the
  device would run again. Those are bounded in the generated bridge (requires
  regenerating the app, or the matching `mob_new` release).

  This makes true a rule that
  `decisions/2026-09-05-input-nifs-are-dirty-io.md` stated and did not apply
  beyond the NIFs in front of it; that record now carries the correction.

  Enforced by a completeness check rather than a list: every registered iOS
  NIF must be classified as blocking, CPU-heavy or prompt, so adding one
  without deciding fails the build. It checks both directions — a flag is
  wrong when it is missing *and* when it is spurious, and the first draft of
  this change flagged two NIFs that do not block.

- **Test-suite races that made every automated verdict unreliable** (MOB-154,
  MOB-119, MOB-123). A 1-in-20 flake corrupted a mutation-testing result and,
  a day later, sent a bisect down the wrong path when it appeared in the same
  run as a real failure.

  `Mob.ComponentRegistry` is a globally-named singleton owning a named ETS
  table, and two `async: true` modules each started it with
  `start_supervised/1` — so whichever test won the race owned it, and ExUnit
  tore it down while the other module was still using it. It now starts in
  `test_helper.exs`, owned by the run.

  Fourteen `on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end)`
  sites across six modules were check-then-act across a process boundary;
  thirteen further modules had each written the same correct workaround
  privately. All now use `Mob.Test.ProcessHelpers`, which gains `stop_pid/2`,
  `await_exit/2` and `eventually/2`.

  All 35 `Process.sleep` calls were classified rather than swept. Twelve are
  `Process.sleep(:infinity)`, which is not a wait. Of the 23 finite ones, 17
  were dealt with and 6 kept — the kept ones measure elapsed time, back off a
  poll loop that has its own deadline, or are a genuine bet that is recorded
  rather than disguised. The 17 either had nothing to wait for (a
  `GenServer.call` from the process that sent the earlier messages is already
  an ordering barrier) or were replaced with the actual barrier: a
  ready-message, `Logger.flush/0`, a monitor, or a bounded poll. See
  `decisions/2026-09-06-tests-wait-for-events-not-durations.md`.

  Also fixes a temp-directory collision between concurrent `mix test` runs:
  `System.unique_integer/1` is unique per VM, so two suites running at once
  (a CI matrix on one box) generated the same fixture directory and each
  `on_exit` deleted the other's files. `ProcessHelpers.tmp_path/1` includes the
  OS pid.

### Changed
- **Input NIFs now run on a dirty IO scheduler.** `tap`, `tap_xy`,
  `long_press_xy`, `swipe_xy`, `type_text`, `delete_backward` and `clear_text`
  on both platforms, plus `key_press`, `ax_action` and `ax_action_at_xy` on
  iOS, are registered `ERL_NIF_DIRTY_JOB_IO_BOUND`. Every one of them blocks
  waiting on the platform UI thread, and Android runs with a single normal
  scheduler (`-S 1:1`), so an 800ms `long_press_xy` was stopping every process
  on the device — timers, renders, `:rpc`, PubSub — for the duration. iOS's
  `nif_long_press_xy` had been sleeping the caller's full duration on a normal
  scheduler since it was written, and `nif_ax_action_at_xy` sleeps up to
  4 x 50ms retrying its lookup.

  **Rebuild native** to pick this up (`mix mob.deploy --native`): the NIF
  registration table is compiled into each app. See
  `decisions/2026-09-05-input-nifs-are-dirty-io.md`.

### Documented
- **Android synthetic input in `Mob.Test`.** The platform matrix now covers
  `long_press_xy/4`, `type_text/2`, `delete_backward/1` and `clear_text/1`, and
  marks `tap_xy/3` and `swipe/5` as working on Android — for apps generated by
  `mob_new` 0.4.32 or newer, since the methods live in the app's own generated
  bridge. The new ⊕ footnote covers what that costs: gestures block for their
  real duration, only the activity's own window is reachable (not dialogs or
  modal sheets), `type_text/2` is ASCII-only and rejects a whole string
  containing one unmappable character, and `clear_text/1` is deliberately
  absent on Android rather than broken.
- **`scroll_info/2` returns device pixels**, while `element_frames/1` returns
  dp and `tap_xy/3` and `swipe/5` take dp. Feeding one into the other
  overshoots by the display density.
- The `✱` footnote no longer attributes `type_text/2`, `delete_backward/1` and
  `clear_text/1` to the IOHID injection path they do not use.

### Added
- **`max_lines` on `:text`.** A positive integer caps the rendered line count
  and tail-ellipsises the overflow: `.lineLimit(n)` + `.truncationMode(.tail)`
  on iOS, `maxLines` + `TextOverflow.Ellipsis` on Android. Unset means what it
  meant before — wrap without limit — so no existing tree renders differently.
  Only native layout knows the measured width, so this could not be done from
  Elixir; trimming the string by grapheme count was the only workaround, and
  it was a guess. `nil` is dropped so a conditional prop stays safe; any other
  value outside the shared platform range of 1 through 2,147,483,647 raises at
  render time. See `decisions/2026-09-07-max-lines-is-a-native-prop.md`.

  Android reads the prop in the app's own generated `MobBridge.kt`, so it
  needs an app generated by the matching `mob_new` release (or a regenerated
  bridge); an older bridge ignores the key and wraps as before.
- **Measured `Wrap` layout on iOS and Android** (MOB-175). `<Wrap>` greedily
  packs children using their rendered native widths and reflows them when
  content or authored text size changes. `spacing` and `run_spacing` accept
  theme spacing tokens, intrinsically oversized children are proposed the
  container width, and `fill_width: true` children occupy a full run. Explicit
  `fill_width: false` now makes a `Box` hug its content while an omitted value
  preserves the historical full-width Box default. The Android renderer ships
  in the matching `mob_new` generated template.

- **Native frame timing on Android.** `Mob.RenderStats.native_enable/1`,
  `native_frames/1` and `native_summary/1` returned `{:error, :unsupported}` on
  Android, which looks identical to "you forgot to enable it". They now work,
  emitting the same JSON shape iOS does so nothing needs per-platform parsing.

  First baseline, physical moto g power, 1600-node screen: a one-field
  re-render costs 266ms p50, a `push` 841ms and a `pop` 916ms — navigation is
  **3.2x** a re-render of the same tree. See
  `decisions/2026-09-05-measure-the-native-half-on-android.md`, which also
  records why the obvious closing brackets (`MessageQueue.IdleHandler`, a
  plain `post`) measure the wrong thing without failing.

  Android's `apply_us` includes a thread handoff and up to one vsync of queue
  latency that iOS's does not; it is a before-and-after tool for one platform,
  not a cross-platform comparison. **Requires an app generated by `mob_new`
  0.4.32 or newer** — the buffer lives in the app's own `MobBridge.kt`.

### Fixed
- `Mob.RenderStats.native_*` now report `{:error, :unsupported}` when the
  running app's bridge lacks the method, instead of leaking
  `{:error, :not_loaded}` past a `@spec` promising two shapes. Android signals
  a missing bridge method by returning rather than raising, so it took the one
  path that was not converted — which every app generated before this release
  hits.

- **`Mob.Test.capabilities/1`** — ask a build which test-harness probes it can
  actually serve, before choosing how to drive it. Which probes work is a
  runtime fact: on Android each harness NIF bails when the app's generated
  `MobBridge.kt` lacks the matching method, and that file is generated once and
  never re-rendered; on iOS the harness is compiled out of release builds.
  Returns `:unknown` per probe for an app predating `mob_nif:capabilities/0`
  rather than guessing, and `false` everywhere with `dist_rpc: false` when
  nothing answered. Backed by a new `mob_nif:capabilities/0` NIF on both
  platforms.

### Fixed
- **A parked screen no longer keeps live resources running, and returning to
  one no longer breaks the frame registry** (MOB-147, MOB-145). MOB-129 keeps
  the navigated-away screen mounted so popping back diffs rather than rebuilds.
  Before it, "not active" and "not alive" were the same state, so several things
  never had to ask which one they were in. A post-merge review found two of them
  silently broken rather than merely wasteful.

  **The frame registry refused every write from a screen you popped back to.**
  The nav generation is still bumped on every navigation and stale writes are
  still refused, but a parked tracker's `onAppear` never fires again — so a
  returning screen kept a stamp two navigations old and was rejected for ever.
  `Mob.Test.element_frames` and `tap_id` read empty for the screen actually on
  display, silently, for exactly the screens the optimisation retains. Trackers
  now re-seed when their slot becomes active. Verified on device: 231 frames
  before a push, 231 again after popping back.

  **Toggles and sliders inherited state across screens.** Neither had any
  prop-to-state sync, relying on the navigation teardown to re-seed them, so a
  screen could show the toggle states and slider positions of the screen two
  navigations back. `Mob.Socket` already raises `ArgumentError` on
  `transition: :none` citing this exact hazard; MOB-129 had made it the default
  path. Both now re-seed when the BEAM's value changes.

  Toggles, sliders and text fields re-seed when their screen becomes active,
  not only when the BEAM's value changes. A value watcher alone misses the
  common case: slots alternate, so a screen reuses the view identities of the
  screen two navigations back, and if both carry the same value — `false` for a
  toggle, `""` for an uncontrolled field — the watcher never fires. For a field
  with `secure: true` that meant a password crossing screens. Focus is dropped
  on park too, so a field holding the keyboard does not arrive focused on the
  next screen.

  Also: a parked sheet is dismissed and re-presented on return, rather than
  staying visible and interactive over the incoming screen — `.sheet` presents
  on the window, so the slot's `allowsHitTesting(false)` never reached it, and a
  park is no longer reported to the BEAM as a user dismissal. A parked video
  pauses (audio included) and only resumes if it was autoplaying; a parked GPU
  view stops rendering, having previously run at 60 fps indefinitely behind the
  visible screen. `on_end_reached`'s latch clears on activation, restoring the
  pre-MOB-129 behaviour that a navigation used to give it for free.

- **A navigation that replaces the stack releases the screen it replaced**
  (MOB-147). The release used to key on the animation name, which got it wrong
  in both directions: `reset_to(..., transition: :push)` is documented, does
  replace the stack, and was read as a push and retained for ever; while
  `switch_tab(..., transition: :reset)` is also documented, does NOT replace the
  stack — a parked tab can be switched back to — and was released, throwing away
  the retention it should have kept.

  The flag now rides on the JSON root, and the transition atom keeps its closed
  `:push | :pop | :reset | :none` vocabulary. A first attempt suffixed the atom
  instead (`:reset_replace`) and that was wrong: `set_transition/1`'s atom name
  reaches Android as a raw string, and `MainActivity.kt` matches those four
  values with an exact `when`, so every `reset_to` would have silently lost its
  animation — on newly generated apps too, and those files are app-owned and
  never re-rendered, so no template fix would have reached existing ones. An
  unknown root key is ignored by both platforms' parsers, so a host that does
  not read it keeps its current behaviour.

- **A reset cross-fades again** (MOB-147). The outgoing screen was released
  synchronously, removing it in the same turn, so it hard-cut rather than fading
  — and with `.transition()` gone there was nothing to animate its removal. The
  release now runs on the animation's completion.

  Still open on MOB-147: the layout cost of a parked slot on a container
  geometry change. Steady state with both slots warm measures 67.6 ms against
  65.7 ms for one, about 3%, which suggests the equality check is doing its job.
  Rotation itself remains unmeasured, and honestly so: a geometry change
  produces no `set_root`, so the frame instrumentation cannot see it at all.


### Performance
- **Navigation no longer rebuilds the whole native tree (iOS)** (MOB-129). The
  root carried `.id(currentNavVersion)`, so every push, pop and reset destroyed
  and rebuilt the entire SwiftUI tree. On a 1614-node screen a navigation cost
  **235 ms** against a 65.7 ms steady-state re-render, and the whole 169 ms gap
  was the identity change: it scales linearly with node count.

  Two slots now sit at fixed positions and a navigation ping-pongs between them,
  with the slide driven by an animated offset instead of `.transition()` —
  which only fires on insert and removal, and insert/removal is exactly what
  costs. A push drops to **76 ms** and a pop to **76 ms**, with the animation
  unchanged.

  The outgoing slot is deliberately kept, which gives depth-1 retention for
  free: popping back diffs against that screen's own previous tree rather than
  rebuilding it. A `reset` releases it, since that screen is unreachable.

  Retention makes the *second* visit cheap regardless of shape. Bouncing
  between the dense screen and a 37-node one, a pop went from 217 ms to
  **66 ms** and a push from 26 ms to **8 ms**, because each screen returns to
  the slot holding its own previous tree.

  **What it does not do.** A first visit to a screen still builds every node
  that does not yet exist, so a linear drill-down pays full price at each new
  level; an alternating back-and-forth pays once per screen. It does not touch
  the 65.7 ms steady-state floor either. And a parked screen is now alive rather than
  destroyed, so a screen parked with a sheet, web view, video or GPU view up has
  open issues recorded on MOB-129. Android is unchanged. See
  `decisions/2026-09-04-two-slot-screen-presentation.md`.


## [0.7.39] - 2026-09-04

### Fixed
- **Interactive elements past the 256th no longer silently stop responding**
  (MOB-133). The tap registry was a fixed 256-entry pair of tables and the
  handle encoding gave slots only 8 bits, so every element past that got the
  `-1` "no handler" sentinel — it still rendered, still looked tappable, and did
  nothing. On a 200-row list that was **359 of 615** elements. Slots now get 12
  bits (4096) and the tables are allocated to fit, starting at the old 256 and
  doubling on demand, so no app pays for capacity it does not use. Verified on
  both platforms by tapping an element past the old cap and watching the counter
  move — Android row #188 (slot ~564) and iOS row #92 (slot ~283). See
  `decisions/2026-09-02-tap-tables-grow-on-demand.md`.

  The generation field drops from 23 bits to 19 to pay for the slot bits: at
  60 fps that is ~2.4 hours before it wraps, and the wrap was already handled
  modularly.

Everything below is unreleased work from the MOB-124 rendering-performance
epic. Nothing here has shipped to Hex.

### Added
- **Native frame timing: `Mob.RenderStats.native_enable/1`, `native_disable/1`,
  `native_frames/1` and `native_summary/1`** (MOB-126). `Mob.RenderStats` measures seven stages
  and every one is on the BEAM side of the boundary: `set_root_us` closes when
  `nif_set_root` returns, and that is the moment the tree is handed to the main
  thread, not the moment it is on screen. Everything SwiftUI does to build, lay
  out and display it happened after the measurement closed, so the native half
  of every frame had never been measured. These read a native ring buffer of
  main-thread busy time per applied tree, tagged with the transition that
  produced it so a navigation rebuild can be told apart from a steady-state
  re-render.

  Off by default; the disabled path is one relaxed atomic load per `set_root`.
  Read it as an upper bound on a frame's native cost rather than an
  attribution: anything else queued on the main thread in the same window is
  inside the number.

  **Debug builds only, and iOS only.** The reading NIFs sit inside the same
  `MOB_RELEASE` guard as the rest of the test harness, so a TestFlight or App
  Store build returns `{:error, :unsupported}` — profile a debug build.
  Android returns `{:error, :unsupported}` too, which is accurate rather than
  silently zero. See
  `decisions/2026-09-03-measure-the-native-half-of-a-frame.md`.

- **`Mob.RenderStats` — per-frame render instrumentation** (MOB-125). Records
  the user's `render/1`, tree expansion, component reconcile, the renderer's
  prepare walk, `register_tap`, `:json.encode`, and `set_root` as seen from the
  BEAM, plus node count, `register_tap` call count and payload bytes. Off by
  default behind a `:persistent_term` flag; readable over dist with
  `Mob.RenderStats.summary/0`, which reports p50/p95/max with the sample size
  `n` per stage. `verify_taps/1` enables an opt-in second walk that cross-checks
  the tap count. See `decisions/2026-09-01-render-instrumentation.md`, including
  why `total_us` must not be compared against a frame budget.

### Performance
- **`:scroll` can build its content lazily, with `lazy: true`** (MOB-128). A
  column that is the direct content of a **vertical** scroll uses `LazyVStack`
  rather than `VStack`, so only the rows on screen are built. Opt-in: rows below
  the fold are never built, so `Mob.Test.element_frames` / `tap_id` cannot
  address them and `scroll_to(:bottom)` under-scrolls, exactly as for
  `lazy_list`. A `row` under a horizontal scroll, and anything deeper than a
  scroll's direct child, stay eager.

  This is the **iOS** half, and it needs mob_new 0.4.31+ for the Android one: an
  app upgrading `mob` alone gets lazy scroll on iOS and not on Android. It is
  verified to render identically to the eager path but its win is **not**
  independently measured on iOS — the equivalent
  Android change (in `mob_new`) measures a 500-row screen going from 498.9 ms to
  115.8 ms of main-thread work per frame. See
  `decisions/2026-09-02-lazy-scroll-on-ios.md`.
- **iOS `set_root` is 47% faster on a dense screen** (MOB-135). The native
  deserialiser probed ~100 prop keys into every node's props regardless of node
  type — 104 probe sites, 99 distinct keys, 8 type guards — to read the three to
  five props a node actually carries. It now enumerates each node's own props
  once and resolves keys to slots. On a 200-row screen (1627 nodes, 207 KB):
  `set_root` 7625 → 4040 µs, whole frame 13002 → 9403 µs. Purely
  native-internal; no wire-format change.
- **`register_tap` no longer logs once per exhausted call** (MOB-133). On a
  screen with more than `MAX_TAP_HANDLES` (256) interactive elements, the
  exhaustion path called `NSLog` synchronously per overflowing node — 359 times
  per frame on a 200-row screen, 13 ms of a 27 ms frame. The count is now
  reported once per frame from `set_root`, taking `register_tap` from 13004 µs
  to 81 µs.
- **`clear_taps` frees only the slots that were used**, instead of walking all
  256 every frame.

### Fixed
- **`ErlNifEnv` leak on the rescued render path** (MOB-133). Bounding
  `clear_taps` by a high-water mark that only `set_root` wrote leaked one
  `ErlNifEnv` per tap, per frame, whenever a render raised between `clear_taps`
  and `set_root` — a path `Mob.Sender.commit/1` deliberately rescues, so it
  accumulated silently. `register_tap` now maintains the mark. See
  `decisions/2026-09-02-register-tap-owns-the-table-high-water-mark.md`.
- **`tap_exhausted_count` no longer leaks across frames.** It was reset only
  inside `set_root`'s reporting branch, so a frame that overflowed and then
  failed carried its count into the next frame's report. Reset in `clear_taps`
  now. The iOS increment also moved inside the tap mutex, matching Zig.
- **`Mob.Sender` no longer discards render-stat frames at navigation
  boundaries.** Both activation paths deleted a queued tree and threw its
  measurement away with it, so dropped frames were undercounted at exactly the
  transitions the epic measures.
- **Staged render-stat frames are swept by age**, so a screen killed between
  `hand_off/1` and `Mob.Sender.render/5` cannot leave an entry nothing claims.
- **Throttle/debounce config now reaches native** (MOB-134). iOS resolved the
  handle against the *pre-swap* tap table, so a screen's throttle settings were
  applied to the outgoing screen's handlers and its own ran unthrottled;
  Android never sent the config at all. iOS now resolves against the table
  being built, and Android sends it per composition. Gestures finally honour
  what the app asked for instead of always using the built-in defaults.

  **Action required if you use `throttle: 0`.** That is the documented escape
  hatch for raw delivery (`guides/events.md`), and because the config never
  reached native, an app asking for it was silently getting the 33 ms default
  instead. It now means what it says: every sample is delivered, which on a
  120 Hz display is four times the rate that screen's mailbox has been
  receiving. Audit any handler that asked for it. The safe direction of this
  fix — a handler that asked to be throttled and was not — needs no action.

  `debounce`, `leading` and `trailing` are accepted and stored but not yet
  acted on by either platform. See
  `decisions/2026-09-02-throttle-config-targets-the-building-table.md` and
  `decisions/2026-09-03-android-throttle-config-per-composition.md`. Needs
  mob_new 0.4.31+ for the Android half.
- **An unchanged tree no longer repaints** (MOB-140). Rendering was driven by
  message arrival rather than by state change, so any message a screen received
  produced a full paint even when it changed nothing — a periodic tick, a
  presence update, a reply a screen ignored. The screen now fingerprints the
  rendered tree together with the current theme and skips the paint when
  neither moved. The theme is part of the fingerprint because token resolution
  happens downstream in `Mob.Renderer`, so a tree that is identical pre-
  resolution can still render differently after a theme change.

  Only `forward/2` may skip; every other paint path is unconditional. Skips are
  recorded through `Mob.RenderStats` with `reason: :unchanged` rather than
  vanishing, so a screen that stops updating stays diagnosable. See
  `decisions/2026-09-03-skip-the-repaint-when-the-tree-is-unchanged.md`.
- **Local-file images are cached instead of re-read on every render**
  (found via MOB-126). `UIImage(contentsOfFile:)` was called inside SwiftUI's
  `body`, and unlike `UIImage(named:)` there is no system cache behind it, so
  every evaluation of an image node re-read the file from disk on the main
  thread. Worst across a navigation, where the root's identity change rebuilds
  the whole tree and every image on the incoming screen is loaded again while
  the transition animates.

  Keyed on (path, size, mtime) so a file rewritten in place is not served
  stale, with the budget scaled to physical memory (16-64 MB) rather than a
  flat ceiling that is only defensible on the largest device it runs on. A
  first visit still loads cold; what goes away is every load after that.

- **`on_end_reached` fires on arrival at the end, not on content replacement**
  (MOB-141). Keying children on the author's `:id` gave every row a new identity
  when a list's contents were replaced, so the last row's `onAppear` ran again
  even though nobody scrolled: a search screen re-queried on each keystroke
  fired one pagination request per keystroke, where before it fired none. The
  callback is now latched on the child count, which survives a replacement
  because only navigation changes the list's identity. Re-querying and getting
  twenty results again is suppressed; loading a page and going twenty to forty
  is not.

  Not a complete fix, and the limitations are worth knowing. A re-query whose
  result count differs every time still fires once per distinct count. A
  **windowed** list holding a rolling buffer at constant length fires once and
  then never again. And a page load that **fails or returns nothing** leaves the
  count unchanged, so scrolling away and back will not retry it, where
  previously `.onAppear` would have.
  Separating "new content, user is at the end" from "new content, user never
  scrolled" needs scroll position rather than content identity. Write
  `on_end_reached` handlers to be idempotent.

- **Children keep their identity across list edits** (MOB-127). Children of
  every container were keyed on their position, so inserting or removing a row
  made each following row adopt the previous occupant's view state: typed text,
  scroll offset, focus and in-flight animations all shifted by one. Children
  now key on the author's `:id` when there is one and on position when there is
  not, so nothing changes for code that never opted in. An authored id and a
  positional key live in separate namespaces, so an author whose id is literally
  `"3"` cannot collide with position 3, and a duplicate id falls back to
  position rather than merging two rows. Numeric ids are coerced to strings, so
  `id: 1` behaves as `id: "1"`.

  Covers column, row, box, both scroll axes, the lazy list, the sheet body and
  the tab bar. The coercion is scoped to top-level props, so an id nested
  inside a prop value — `tabs: [%{id: 1}]` — still falls back to positional.

  Needs mob_new 0.4.31+ for the Compose half, which derives keys the same way
  from the same rules for those seven containers. **The tab bar is iOS-only**:
  Compose's `NavigationBar` still iterates tabs positionally, so reordering or
  inserting a tab moves per-tab state on Android and not on iOS. See
  `decisions/2026-09-03-children-key-on-author-id.md`.

### Known issues (found while measuring, not fixed here)
- *(none outstanding — the two recorded here, MOB-133's 256-element cap and
  MOB-134's unreachable throttle config, were both fixed in this same
  unreleased window and are described under Fixed above.)*

## [0.7.38] - 2026-08-31

### Fixed
- **Stale native callbacks no longer route to replacement handlers.** Android
  and iOS event handles now carry their render generation, with the handler
  table, count, and generation committed atomically. Taps and gestures from an
  old native tree are rejected; change-family events and animation-delayed
  sheet dismissals may cross any number of renders while the slot's consecutive
  committed registrations retain identical PID and tag identity, preserving
  in-flight intent without permitting delivery to a replacement handler.
  Building tables are generation-invalid until committed, so stale lookup
  cannot observe partially rebuilt handlers.
  Event tags are copied while their registry lock is held, closing the iOS and
  Android environment-lifetime race. Persistent component handles also carry a
  per-slot generation so callbacks from reclaimed slots cannot reach a new
  component. Rejections are visible in debug native logs. Generated Android
  projects must pair this with mob_new 0.4.30+, whose generated bridge keys
  lazy-list state independently of the full event handle — older templates
  reset list scroll position on every re-render under 0.7.38. Deregistering
  an already-stale component handle now returns `badarg` rather than silently
  succeeding. (#114)

## [0.7.37] - 2026-08-30

### Added
- **Directional tab switches with first-mount parameters.**
  `Mob.Socket.switch_tab/3` and `Mob.Test.switch_tab/3` take
  `transition: :push | :pop | :reset` and `mount_params` delivered to a stack
  root on its first mount (revisits keep the original params). Tab-switch
  frames carry an activation token, so a repaint that began while a screen
  was parked can no longer consume the navigation frame or blank the
  incoming screen. (#111)
- **Opt-in all-stack reset for session boundaries.** `Mob.Socket.reset_to/4`
  accepts `scope: :all` (mirrored by `Mob.Test.reset_to/4`): stops every
  screen in every stack — parked ones included — and clears all persisted
  screen snapshots without restoring one into the replacement. Built for
  sign-out. (#111)

### Fixed
- **Component processes are reclaimed when their owning screen exits.**
  Components monitor their owner: on screen exit they terminate and release
  their native handle-pool slots (before user `terminate/2`, so a raising
  callback cannot skip reclamation), and a terminating component can no
  longer deregister a replacement that has reclaimed its id. Fixes native
  handle exhaustion under tab churn. (#111)

## [0.7.36] - 2026-08-30

### Fixed
- **iOS honors a Box's fixed `height` without a fixed `width`.** `fixedHeight`
  was only applied inside the fixed-width branch, so a width-less `height:`
  box rendered at intrinsic height on iOS while Android honored it; it now
  sizes to the requested height and keeps the full-width default, with
  background, border, and tap target covering the full frame. A weighted box
  with a fixed height still lets weight win, matching Android. (#104)
- **Host runs log the theme NIF load failure once, not on every call.**
  `Mob.Theme` caches the `mob_nif` availability probe in `:persistent_term`
  (first probe serialized; re-probed when the module loads), so host test
  runs lose the per-call `Failed to load NIF library` noise while device hot
  code loads still restore the native color-scheme path. A genuine device
  load failure still reports once via the standard OTP on_load error. (#108)

## [0.7.35] - 2026-08-30

### Added
- **Coordinate taps report observed effect; pixel sampling for rendered
  colour (iOS harness).** `Mob.Test.tap_xy/3` returns `:ok` only when a UI
  event actually reached the BEAM within the settle window; otherwise
  `{:error, :no_view_at_point}`, `{:error, :no_element_at_point}` (simulator),
  or `{:error, :no_effect}` — never a phantom success. New
  `Mob.Test.sample_color/2` samples real rendered pixels by element id or
  rect (debug builds only) for exact-colour assertions. The effect counter
  is process-wide: one driver per device at a time. (#80)

### Fixed
- **iOS honors layout `weight` in stacks.** Previously parsed only by the
  Android bridge; a weighted child on iOS hugged its content. Now every node
  type (including `:spacer`) expands to fill remaining space along a
  column/row's axis, the expanded frame is tappable, and the frame registry
  reports expanded geometry. Multiple weighted children divide remaining
  space evenly on iOS; Android additionally honors unequal numeric ratios —
  see the components guide. (#98)
- **The initial transition survives mount-time repaints.** The router now
  reserves the navigation transition at activation and the first committed
  frame for that screen consumes it, so a screen that repaints from `mount/3`
  (the standard data-loading pattern) no longer races its `:none` frame ahead
  of the animated one — previously a lost or doubled transition. Makes
  `reset_to/4`'s directional `:push`/`:pop` wipes reliable for screens that
  load data on mount. (#103)
- **Cold-start launch notifications are no longer dropped (Android + iOS).**
  `mob_set_launch_notification` (Android) and `mob_set_launch_notification_json`
  (iOS) bailed out when called before `nif_load` had created their mutex — which
  is exactly when the cold-start path runs (MainActivity.onCreate / the app
  delegate store the tapped notification before the BEAM boots). The payload
  was silently discarded, so tap-to-open from a killed app never worked; a
  warm/backgrounded tap (delivered via `onNewIntent` / the running delegate)
  was unaffected. Both setters now store before the mutex exists, the same
  pre-mutex pattern `mob_set_opened_document` has always used — safe because
  nothing reads the global until `take_launch_notification`, which can only
  run post-`nif_load`.

### Documentation
- Guides realigned with the screen-process architecture that landed in
  0.7.33: navigation (multi-stack/tab state, no-chrome-yet status and the
  `:tab_bar` widget interim path), screen lifecycle, testing
  (`Mob.ScreenCase`, `Mob.Test.settle/2`, tap-effect contract, colour
  sampling), and getting-started examples corrected to APIs that exist
  (`Mob.Socket.push_screen/2,3`; taps arrive as `{:tap, tag}` in
  `handle_info/2`). The agentic coding guide is split into "working with
  one agent" and "working with agent teams". Surface-matrix corrections and
  hexdocs-themed mermaid diagrams. (#92, #91, #105)

## [0.7.34] - 2026-08-29

### Added
- **Directional resets.** `Mob.Socket.reset_to/4` takes
  `transition: :push | :pop | :reset`. A reset always replaces the navigation
  stack; the option only changes the animation, for cases like a custom tab bar
  where replacing the stack still represents directional movement. `:reset`
  (cross-fade) remains the default, so existing callers are unaffected.
  `Mob.Test.reset_to/4` takes the same option, so the behaviour can be driven
  on a device.

### Changed
- The `:reset` navigation action carries a fourth element, the transition.
  `Mob.ScreenCase.navigated_to/1` and the router understand both shapes; the
  three-element form still arrives from `Mob.Test.reset_to/3` and from any
  socket built before a hot code push.
- An unrecognised transition now raises `ArgumentError` at
  `Mob.Socket.reset_to/4` rather than reaching the platform, which accepts any
  atom and silently falls back to no animation. `:none` is rejected for the
  same reason it is not merely a typo: it suppresses the navigation-version
  bump, so SwiftUI would diff the incoming tree into the outgoing screen's view
  identities — a text field at the same position keeping the old screen's text
  and focus across a stack that no longer exists.

### Fixed
- **A navigation action the router does not recognise no longer takes the app
  down.** It was an unmatched function clause in the owner process, which owns
  navigation and links every live screen — so one bad action killed all of
  them. Reachable during a hot code push, where module loading is not atomic
  and a screen already running new code can hand an action to a router still
  running old code. Now logged and ignored, and the current screen repaints.

## [0.7.33] - 2026-08-29

First four steps of the screen-process architecture (MOB-108). Rationale in
`decisions/2026-08-27-screen-process-architecture.md`; each step has its own
decision record alongside it. **No `.m`, `.zig`, or generator-template change
was required** — the whole move happens above the native boundary.

### Added
- **One process per live screen, with real crash isolation.** A crash in a
  screen's `handle_event` no longer takes down navigation, sibling screens, or
  the BEAM: the owner observes the exit, restarts that screen, and repaints.
  `Mob.Screen`'s moduledoc had claimed this for a long time and mob#76 had to
  correct it; the claim is now true. A restarted screen re-mounts and loses its
  assigns — persisted screens (`use Mob.Screen, vsn: N` or `persist: true`)
  recover through `load_state/2` — and the restart is logged, because a form
  clearing itself is visible to the user. Restarts are capped (5 in 10s per
  screen) so a screen that crashes on every render cannot spin.
- **Multi-stack navigation.** `Mob.App.tab_bar/1` and `drawer/1` have been
  public API in `Mob.App`'s own moduledoc while the runtime behind them could
  hold exactly one history, and `Mob.Socket.switch_tab/2` did nothing at all.
  Each declared stack now owns its own history *and* its own live screen, so
  switching away and back restores where you were instead of re-mounting.
  Stacks materialize on first visit, matching `UITabBarController`.
- `Mob.Nav` — multi-stack navigation state. `Mob.Sender` — the single process
  permitted to call the render NIFs. `Mob.Listener` — the single inbound entry
  point from native. `Mob.Screen.Server` — one per live screen.
- `Mob.Screen.get_screen_pid/1`, for reaching the process that actually holds
  the screen on show.
- `Mob.Test.settle/2`, which waits for a frame to reach the screen. Prefer it
  over `:sys.get_state/1` after any fire-and-forget call (`tap/2`, `back/1`,
  `send_message/2`) before reading the native side with `view_tree/1`,
  `screenshot/2`, `tap_id/2` or `element_frames/2`.

### Changed
- **`self()` inside a screen callback is now that screen's own pid**, not the
  process registered as `:mob_screen`. This is what user code already assumed
  when writing `on_tap: {self(), :save}` or starting a task from a screen.
- Rendering is serialized through `Mob.Sender` and committed asynchronously.
  The native tap tables share one global build cursor, so two screens rendering
  concurrently would interleave their handles and one screen's tree would never
  commit. Only the screen on show can commit a frame; a background screen's
  repaint is dropped rather than painted over the foreground.
- `__mob_hot_reload__` is a broadcast — every live screen repaints with the new
  code, not just the one on show.

### Fixed
- **Async results are no longer delivered to the wrong screen** (MOB-107,
  reported by @minibikini). A task started by screen A that completed after
  navigating to B was handed to B's `handle_info` with B's socket; where both
  used the same message shape, B silently processed A's payload, and where B
  did not match, the generated catch-all swallowed it with no log and no crash.
  Work started by a screen now goes to that screen's own pid: if it has been
  popped and stopped the BEAM drops the message, and if it is alive in another
  tab it handles it and its state is current when you switch back.

### Known gaps
- **Nothing renders a tab bar yet.** `tab_bar/1` and `drawer/1` are now backed
  by the runtime and `switch_tab/2` works, but no tab bar or drawer chrome is
  drawn — switching is programmatic for now.
- `reset_to/2` does not re-derive which stack its destination belongs to
  (MOB-115); parked screens miss `terminate/2` and state sync (MOB-116);
  re-selecting the active tab does not pop that stack to its root (MOB-117).

## [0.7.32] - 2026-08-27

### Added
- **Intrinsic Sheet detents.** `Mob.UI.sheet/2` accepts `[:content]` and
  `[{:content, max_height: n}]` alongside `[:medium, :large]`. A content detent
  is exclusive of the built-ins; existing `:medium`/`:large` callers are
  unaffected. Validation is canonical through
  `Mob.UI.normalize_sheet_detents!/1` and re-applied at the renderer boundary,
  so a hand-built or `~MOB` sigil node cannot bypass it — such a node now
  raises during render rather than silently degrading. Content detents encode
  as typed native configuration maps.
- **iOS content measurement.** A content sheet measures its composed Mob
  children, hugs short content, caps at the configured maximum and at live root
  geometry, scrolls overflow internally, and re-clamps when the root or
  container resizes (rotation, split view, Stage Manager). The detent accounts
  for the sheet's own bottom safe-area inset, so content clears the home
  indicator instead of sitting under it.
- **Composite Box accessibility.** `accessibility_label`, an explicit
  `accessibility_role: :button` that survives independently of whether an event
  handle is present, and `disabled` semantics. A disabled interactive Box stays
  a disabled button and does not dispatch; a passive labelled Box does not
  become a button.
- `Sheet` is now in both packaged platform tag manifests (`priv/tags/ios.txt`,
  `priv/tags/android.txt`), so the `~MOB` sigil and `Mob.ScreenCase` accept it.

### Fixed
- **iOS launch deadlock (App Store rejection).** `Mob.Screen.init/1` calls
  `safe_area()` before the first screen mounts, and its unbounded
  `dispatch_sync` to the main queue could block the BEAM boot thread forever —
  the app never finished launching. Root-caused from an App Store review
  reporting an indefinite load on an iPad Air M3. Now a bounded wait (2s) that
  falls back to zero insets, with the NIF marked
  `ERL_NIF_DIRTY_JOB_IO_BOUND` so a slow main thread cannot stall a regular
  scheduler either. A 15s boot watchdog turns a silent hang into a diagnosable
  error rather than a blank screen.
- iOS: glass surfaces tint with the node's own background, and `fill_width`
  rows left-align rather than centring.
- `@external_resource` on the platform tag manifests. They are read into module
  attributes at compile time, so adding a tag previously recompiled nothing for
  anyone with a warm `_build` — every path-dep consumer — leaving the sigil
  rejecting the new tag until a manual `mix clean`.

### Known limitations
- A content detent sizes from *intrinsic* height, so a scrollable child
  (`scroll`, `lazy_list`) reports its full content height and expands inside
  the sheet rather than scrolling independently. Use `:medium`/`:large` when
  the sheet body is itself scrollable.
- A content sheet presents at `:medium` for the first frame and resizes once
  its content has been measured, since content height is only knowable after
  presentation.

## [0.7.31] - 2026-08-27

### Added
- **`mob_send_dismiss` (Android)** — sends `{:dismiss, tag}` to the process
  registered for a handle, the shape `Mob.UI.sheet/2` documents for
  `:on_dismiss` and the one iOS has always delivered. Android had no dismiss
  sender at all, so generated sheet renderers fell back to the tap sender and
  delivered `{:tap, tag}`; a screen written to the documented contract never
  matched it and died with `FunctionClauseError` (or silently dropped the
  dismissal and could never re-present the sheet). Pairs with mob_new 0.4.24,
  which adds the `nativeSendDismiss` extern and JNI thunk — **generated Android
  apps need both halves**, and `mix mob.doctor` (mob_dev) now warns about a
  project still carrying the old wiring (MOB-104).

  **Behaviour change for Android:** if you worked around the old bug by
  matching `handle_info({:tap, tag}, ...)` for a sheet dismissal, that clause
  is now dead — switch it to `{:dismiss, tag}`. iOS callers are unaffected;
  they always received `{:dismiss, tag}`.

### Fixed
- iOS: a frame tracker whose write was refused (an outgoing screen mid-nav)
  no longer loses its ownership token. It kept the last returned value even
  when that was the "refused" sentinel, which silently disabled its own
  cleanup — so if the incoming screen's element with the same `:id` wasn't
  laid out (a lazy row below the fold), the old screen's entry survived and
  `Mob.Test.tap_id/2` tapped its coordinates.

### Changed
- `Mob.Test.element_frames/1` docs now say plainly that the drop-when-not-laid-out
  behaviour is **iOS only**. Android clears frames wholesale on a navigation
  transition and never per element, so a scrolled-away row still reports a
  position there — the previous wording read as cross-platform.

---

## [0.7.30] - 2026-08-27

### Fixed
- **`Mob.Test.element_frames/1` no longer reports elements that are in the
  render tree but not on screen.** 0.7.29 shipped a fix (MOB-102) that stopped
  wiping the frame registry on every render and instead dropped only ids
  absent from the incoming tree. That fixed static elements vanishing, but
  "in the tree" is not "on screen": a `lazy_list` row scrolled out of range,
  an inactive tab's subtree, and a dismissed sheet's content all stay in the
  tree, so their last on-screen frame was reported indefinitely — and
  `Mob.Test.tap_id/2` would tap whatever now occupied those coordinates. It
  now returns `{:error, :not_found}` for them again, as it did before 0.7.29.
  Tracked elements drop their own entry when the platform stops laying them
  out, via a compare-and-delete so an outgoing screen can't remove an entry an
  incoming screen just claimed under the same `:id` (MOB-103).
- **A screen animating out of a nav transition no longer re-registers itself
  at mid-animation coordinates.** `set_root` applies the new tree
  asynchronously on the main thread, so an outgoing screen kept reporting
  frames after its ids had already been purged — including when both screens
  tagged an element with the same `:id`, which tree membership alone can't
  reject. Writes are now refused for ids absent from the current tree, and for
  any tracker belonging to a superseded navigation (MOB-103).
- **A list delete no longer loses the frame of the element below it.** Every
  `ForEach` keys children by index while the registry is keyed by `:id`, so
  removing an item shifts each later id onto a different tracker; with
  same-height rows nothing re-registered and the surviving element went
  missing. Trackers now re-register when the `:id` beneath them changes, and
  on appearance (MOB-103).

### Changed
- `Mob.Test.element_frames/1`'s docs now state what counts as rendered, and
  that a frame is a last-known position recorded at layout — poll until it
  settles rather than trusting the first read after a change.

---

## [0.7.29] - 2026-08-27

### Added
- **`Mob.UI.sheet/2`** — a native modal bottom sheet (iOS `.sheet`,
  Android Material 3 `ModalBottomSheet`) that composes ordinary Mob
  nodes as content. `:detents` (`[:medium, :large]` subset), `:on_dismiss`
  (delivered as `{:dismiss, tag}`, exactly once), `:background`, `:scrim`,
  `:corner_radius`, and a custom drag indicator (`:drag_indicator_color`/
  `_width`/`_height`/`_rail_height`, all four required together or omit
  all four). Per-platform `:ios`/`:android` style overrides via the
  existing platform-block mechanism. See
  `decisions/2026-08-26-native-sheet-primitive.md` for the presentation-
  state-via-identity design, the background/corner_radius
  double-application avoidance on both platforms, and the documented iOS
  scrim-opacity limitation (native `.sheet` doesn't expose dimming-layer
  opacity — Android applies `:scrim` exactly, iOS stays system-black).

### Fixed
- Drag-indicator completeness validation (all four geometry props
  together or none) is now checked against `:ios`/`:android` overrides
  merged with the base props, not just the base props alone — a partial
  override no longer silently passes validation and renders the system
  default indicator instead of the requested one.
- Color props that resolve to neither the active theme nor the base
  palette now log a warning instead of silently passing an unresolved
  atom through to native (previously a likely typo'd theme token would
  render as an invisible, fully-transparent color with no signal at all).
- iOS: sheet content now receives its `:padding` (was dropped).
- iOS: `corner_radius: 0` on a sheet is no longer indistinguishable from
  "not set" — square corners are now representable and distinct from the
  system default.
- iOS: a sheet's `:id` no longer reports a 0x0 frame via
  `Mob.Test`/`element_frames` — its switch-case view is an invisible
  presentation anchor, not the sheet's real on-screen content, so frame
  tracking is skipped there rather than publishing a value known to be
  wrong.
- iOS: `Mob.Test.element_frames/1` no longer drops a still-visible element
  that didn't move. The registry was cleared on every render on the
  assumption that frame tracking would repopulate it, but tracking only
  fires when an element's frame *changes*, so anything that stayed put
  went missing until something moved it. Only ids absent from the incoming
  tree are dropped now (MOB-102). See
  `decisions/2026-08-27-frame-registry-purge-by-id.md` — and note the 0.7.31
  entry above, which corrects the converse case this introduced.
  *(Documented after the fact: this shipped in 0.7.29 but was omitted from
  its notes, so it is not in the published 0.7.29 changelog.)*

## [0.7.28] - 2026-08-26

### Fixed
- **Native component handle pool exhaustion crashed the screen process.**
  A screen registering enough `Mob.UI.native_view`/`Mob.Component`
  instances to fill the fixed pool (originally 64 slots) got the same
  `badarg` as a malformed pid, which crashed `Mob.ComponentServer.init`
  and, via the unmatched `{:error, _}` in `Mob.Component.ensure_started`,
  the whole screen — every tap went dead until force-kill. Three
  compounding defects, all fixed:
  - A full pool now returns `{:error, :component_slots_exhausted}`
    instead of `badarg`; `Mob.ComponentServer` logs and fails just that
    one component, leaving the screen alive.
  - Slot 0 (a legitimate pool index) was conflated with the `:no_render`
    sentinel (also `0`), so `terminate/2` never deregistered it —
    permanent leak. The sentinel is now `-1`.
  - `Mob.ComponentServer` never trapped exits, so
    `Mob.ComponentRegistry.reconcile/2`'s `Process.exit(pid, :shutdown)`
    (the real production stop path) never ran `terminate/2` at all —
    *every* component leaving a screen leaked its slot, not just the
    slot-0 ones. This was the dominant leak, found while writing the
    regression test against the real stop path. See
    `decisions/2026-08-26-component-pool-trap-exit.md`.
  - `MAX_COMPONENT_HANDLES` bumped 64 → 256 on both platforms as
    headroom (still fixed-size; a growable pool is a longer-term
    follow-up).
  - Also hardened against version skew: a native binary predating this
    fix (reachable via `mix mob.push` hot-deploying a newer BEAM without
    a native rebuild) returns a bare int on success and raises on
    exhaustion — `Mob.ComponentServer` now degrades to the sentinel
    instead of crashing in that case too.
  - **Sibling bug in the tap-handle pool.** `nif_register_tap` (both
    platforms) had the identical crash-on-exhaustion bug for
    `on_tap`/`on_change`/`on_focus`/etc — reachable by any screen with
    more than 256 interactive elements (an unvirtualized long list or a
    big form). Fixed with the same `-1`-sentinel approach; needed no
    `Mob.Renderer` changes since every native sender already no-ops on
    an out-of-range handle.
  - Device-verified on a physical Android phone and the iOS simulator
    for both pools. (MOB-100)

## [0.7.27] - 2026-08-26

### Fixed
- **Native component events (`Mob.UI.native_view`/`Mob.Component`) arrived
  as Erlang charlists, not binaries.** Both native bridges
  (`android/jni/mob_nif.zig`, `ios/mob_nif.m`) built the event name and
  JSON payload via `enif_make_string`. `Mob.ComponentServer` decodes the
  payload with `:json.decode/1`, which requires a binary — the component
  process crashed before `handle_event/3` ever ran. Both bridges now emit
  UTF-8 binaries; `Mob.ComponentServer` also normalizes at the boundary
  (accepts either shape, for a hot-deployed newer BEAM landing on an
  older native shell) and no longer crashes on a malformed or
  unexpected-shape event/payload — falls back safely and logs instead.
  (MOB-98)
- **iOS accessibility-tree hit-testing (`Mob.Test.tap_id/2`,
  `ax_action_at_xy/2`, `long_press_xy/2`) could race a very recent
  layout or navigation.** SwiftUI populates its accessibility tree
  lazily; a synthetic tap issued the instant a screen mounts (the
  common automated-test pattern) could return `:no_element_at_point`
  even though the element's tracked *frame* was already correct — the
  two mechanisms settle on different timelines. The point-based lookup
  now retries a few times with a short delay before giving up, with
  find-then-act happening atomically per attempt (an earlier, separate
  find/act split risked acting on a stale window or a recycled
  table/collection-view cell). (MOB-99)
- **`mix test` was intermittently flaky**: `test/mob/component_test.exs`'s
  `Mob.ComponentRegistry` describe block used `start_supervised!/1`-style
  strict matching against a fixed-name GenServer that
  `test/mob/component_server_test.exs` (added for MOB-98) can legitimately
  start first under `async: true` — the second file to run raised on
  `{:already_started, _}` instead of tolerating it. Now matches the
  tolerance MOB-98 already added on the other side.

## [0.7.26] - 2026-08-25

### Fixed
- **`Mob.Plugins.read_path/1` silently swallowed a malformed manifest.** A
  syntax error or raised exception while evaluating `priv/mob_plugins.exs`
  was rescued straight to the empty manifest with no log line — "this app
  has no plugins" looked identical to "the manifest evaluated cleanly to
  nothing." The exception is now logged before falling back, matching
  every other rescue in this module (`invoke_handler/3`,
  `notification_match?/3`).

### Documentation
- **`MOB_PLUGINS.md`'s schema reference now explicitly states that theming
  doesn't ride this manifest.** `styles:` / `default_style:` belong to a
  separate file (`priv/mob_style.exs`, see `MOB_STYLES.md`) validated by a
  separate module. The two manifests share enough vocabulary ("manifest")
  and shape to invite declaring one in the wrong file, where it validates
  cleanly and then goes nowhere — nothing warned about this before.

Both found via a real report from someone building a style plugin
against this system.

## [0.7.25] - 2026-08-25

### Added
- **Custom fonts: named tokens, app-wide/plugin defaults, and a fallback
  chain — see [`MOB_FONTS.md`](MOB_FONTS.md) for the full design.**
  - `Mob.Theme.font/2` builds a `%{ios:, android:}` font spec from an iOS
    PostScript name + `from_file:`, computing the Android resource name via
    the same helper the build-time bundler uses (`Mob.Font.android_resource_name/1`),
    so the two names can't drift apart.
  - `Mob.Theme` gained `fonts` (a name → spec map, referenced from a node's
    `font:` prop the same way `:primary`/`:on_surface` reference the color
    map) and `font_fallback` (an ordered list of specs tried, in order, on
    either platform when a node's own font name doesn't resolve).
    `Mob.Theme.fonts_map/1` and `font_fallback_list/1` are the accessors.
  - Any node that doesn't set its own `font:` prop picks up the theme's
    `fonts[:default]` automatically (`Mob.Renderer.inject_font_default/2`) —
    set an app-wide default font once, no per-node wiring.
  - A capability plugin can declare its own default font via
    `default_font: %{family:, file:}` in `priv/mob_plugin.exs`
    (`Mob.Plugins.apply_default_font/0`, run at boot right after the host's
    own style/font setup, so a host-set default always wins over a
    plugin's).
  - Both platforms honor the fallback chain natively: Android walks
    `[primary] + font_fallback` via `Typeface.create`, skipping any name
    that resolves to `Typeface.DEFAULT` (Android's silent signal that a
    name wasn't found — see Fixed, mob_new); iOS walks the same list via
    `UIFont(name:size:)`, which correctly returns `nil` for an unknown name.
  - `Mob.Theme.set/1`'s native push (`notify_native/1`) now also ships
    `_font_fallback` to both platforms alongside the existing color palette.

### Fixed
- **Data race on `g_font_fallback` in `ios/mob_nif.m`.** The fallback list
  was a plain `static NSArray *` written from the BEAM's calling thread in
  `nif_set_theme` and read from the main thread in `mob_font_fallback()`
  during SwiftUI render, with no synchronization. Under ARC, the
  unsynchronized write releases the old array while a concurrent reader may
  have just loaded that pointer — a rare but real use-after-release crash.
  The write now hops onto the main thread via `dispatch_sync`, matching
  every other NIF in the file that mutates state the main thread reads.
  Found in code review immediately after this feature's own device
  verification; Android's equivalent (`MobBridge.kt`'s `fontFallback`) had
  always had this covered via `@Volatile`. (MOB-94)

## [0.7.24] - 2026-08-20

### Fixed
- **Android `Mob.Device.orientation/0` could read freed memory.**
  `mob_send_orientation_changed` stored the raw JNI string pointer handed to it
  by the trampoline in `android/jni/beam_jni.c.eex`, which releases that buffer
  as soon as the call returns (`GetStringUTFChars` / `ReleaseStringUTFChars`).
  Any later `Mob.Device.orientation/0` call built its return atom from that
  dangling pointer. The sibling network-connectivity code hit the identical
  hazard earlier and fixed it by caching an int code instead of the string;
  orientation now does the same (`orientationCode/1` / `orientationAtomName/1`,
  mirroring `transportCode/1` / `transportAtomName/1`). Device-verified across
  all four orientations on a physical Moto G Power. (MOB-46, from the 2026-07
  mob ecosystem audit)

## [0.7.23] - 2026-08-19

### Fixed
- **Docs: dropped the "60 of Mishka Chelekom's 70+" component-count claim.**
  That subset framing implied a clean 1:1 mapping between the web library's
  components and the Mob port that doesn't hold up — several web `*_field`
  variants collapse into Mob's own `:text_field` primitive plus one shared
  Field wrapper, some web components map to Mob's core built-in node types
  rather than Mishka-specific ports, and a few (Device Mockup) are web-only
  concepts with no mobile equivalent. `guides/packages.md`, `guides/styling.md`,
  and `guides/theming.md` now describe the web library's 70+ components and
  the Mob port's growing set independently, without a false-precision fraction.

## [0.7.22] - 2026-08-19

### Fixed
- **Docs: Mishka Chelekom's component count was understated.** The 0.7.21
  guides said "60+ ported components" without noting the source library is
  actually 70+ components strong on the web (only 60 ported to Mob so far).
  Corrected in `guides/packages.md`, `guides/styling.md`, and
  `guides/theming.md`.

## [0.7.21] - 2026-08-19

### Added
- **Docs: point to Mishka Chelekom from the packages, styling, and theming
  guides.** New "Component kits" section in `guides/packages.md` covering
  Mishka Chelekom's 60+ ported components (native SwiftUI/Compose, driven by
  Mob's theme tokens), with cross-references from `guides/styling.md` and
  `guides/theming.md`. Also documents the Linear (team MOB) issue-tracking
  convention in `CLAUDE.md` and fixes a malformed `.gitignore` line that had
  silently disabled `.DS_Store`/`.playwright-mcp/` ignoring.

## [0.7.20] - 2026-07-11

### Changed
- **iOS `screenshot/3` can now be opted into release builds.** The iOS test
  harness is stripped from release (`#if !MOB_RELEASE`) because its
  synthetic-input NIFs (`tap`, `type_text`, …) use private UIKit/IOKit selectors
  the App Store auto-rejects. `screenshot/3` uses only public APIs
  (`UIGraphicsImageRenderer` + `drawViewHierarchy`) but was collateral, so a
  shipped app couldn't be screenshotted — an agent driving it over dist couldn't
  see the screen to error-correct (it returned `:not_loaded`). `screenshot/3` and
  its registration are carved into `#if !MOB_RELEASE || defined(MOB_ENABLE_SCREENSHOT)`.
  Default behaviour is unchanged (still stripped); a host opts in with
  `-DMOB_ENABLE_SCREENSHOT`, plumbed from `mob_dev`'s `ios_release_screenshot: true`
  config. The private synthetic-input NIFs stay strictly `#if !MOB_RELEASE` and
  can never ship — a release build can SEE the screen but never DRIVE it. Opt-in by
  design: screenshot captures the app's own window with no OS prompt or indicator,
  so shipping a remotely-triggerable capture must be a conscious choice. (#71)

## [0.7.19] - 2026-07-10

### Fixed
- **`Mob.Motion` iOS `accel` now matches Android's units and sign.** The iOS NIF
  emitted CoreMotion's `userAcceleration + gravity` verbatim — in G (~1.0), not
  the documented m/s² (~9.81), and in iOS's own convention where the gravity
  vector points down (the up-axis reads −g at rest), the opposite of Android's
  specific-force convention (+g on the up-axis). iOS `accel` was therefore off
  from Android by both a scale factor and a sign, so a tilt- or shake-driven UI
  barely moved on iOS and moved backwards when it did. Now emits
  `(userAcceleration − gravity) × 9.80665`, which is Android's `a_coord −
  g_field` exactly — +g up at rest, m/s², correct for both the static tilt term
  and the dynamic linear term. `gyro` (rad/s) and `mag` (µT) already matched and
  are unchanged. The `accel` convention is now a documented contract in the
  `Mob.Motion` moduledoc. (#70)

## [0.7.18] - 2026-07-07

### Added
- **`Mob.Audio` output probes — "is sound actually coming out right now."** The
  audio analog of `screenshot`. `Mob.Audio.output_status/0` →
  `%{volume, muted, route, other_audio}` (cheap, no permission; catches the
  common silence causes — muted, zero volume, dead route — via iOS
  `AVAudioSession` / Android `AudioManager`). `Mob.Audio.output_level/1` →
  `{rms_db, peak_db} | :silent | {:error, reason}`, the actual signal energy of
  `Mob.Audio`'s own player (iOS `AVAudioPlayer` metering; Android `Visualizer`
  on the player's session, needs runtime `RECORD_AUDIO`); `source: :mix` returns
  `{:error, :unsupported_on_platform}`. (#54)
- **`Mob.Audio` input-level metering — the agent "ears" (MOB-35).**
  `Mob.Audio.start_input_metering/1`, `input_level/0`, `stop_input_metering/1`;
  `input_level/0` returns `{rms, peak} | :silent | {:error, reason}` — the same
  shape as `output_level`, so mic and output read through one unified metering
  contract. NIF declared in `mob_nif.erl`; pure `decode_level/1` host-tested.
  (#67)

---

## [0.7.17] - 2026-07-04

### Added
- **Keep-awake / idle-timer (`Mob.Device.keep_awake/1`).** `keep_awake(true)`
  prevents the screen auto-dimming/locking (for video, reading, navigation, or
  any watch-without-touch screen); `false` releases it. No permission on either
  platform. iOS: `UIApplication.isIdleTimerDisabled`; Android: the window's
  `FLAG_KEEP_SCREEN_ON` (the Kotlin bridge ships via mob_new 0.4.19+). The flag
  is app-scoped and cleared by the OS on background — re-assert on resume.
  Device-verified both directions on moto g power (2021) — `dumpsys` shows the
  `KEEP_SCREEN_ON` window flag toggle, and the screen actually sleeps with it
  off / stays lit with it on — and iPhone SE (3rd gen). (MOB-20, #66)

## [0.7.16] - 2026-07-04

### Added
- **Network / connectivity state (`Mob.Device.network_state/0`).** Returns
  `%{online, transport, expensive, validated, constrained}`: online/offline, the
  active transport (`:wifi | :cellular | :wired | :other | :none`), whether the
  link is metered/`expensive`, plus two single-platform signals that report the
  atom `:unavailable` where the OS can't answer (never a misleading `false`) —
  `validated` (Android `NET_CAPABILITY_VALIDATED`, a real-internet probe;
  `false` on a captive portal) and `constrained` (iOS Low Data Mode). Adds
  `online?/0` and a `:network` subscribe category delivering
  `{:mob_device, :connectivity_changed, state}` on change. iOS `NWPathMonitor`;
  Android `ConnectivityManager.NetworkCallback` (Kotlin bridge ships via mob_new
  0.4.18+). Device-verified on iOS simulator and moto g power (2021). (MOB-14, #62)

### Documentation
- Getting-started: fix an undefined `tap/1` in the "first screen" example (#63),
  and make the `0xAARRGGBB` color format explicit vs CSS hex (#64).

## [0.7.15] - 2026-07-04

### Added
- **Torch / flashlight support (`Mob.Torch`).** `Mob.Torch.on/1`, `off/1`, and
  `set/2` toggle the rear-camera torch — a lightweight core capability that needs
  no camera capture session and no permission. On a device with no flash unit
  (tablets, the iOS simulator) it's a no-op, not an error. On/off only for now
  (iOS brightness levels / Android per-torch strength are a follow-up). iOS:
  `AVCaptureDevice.torchMode`; Android: `CameraManager.setTorchMode` (the Kotlin
  bridge ships via mob_new 0.4.17+). Device-verified on moto g power (2021) and
  iPhone SE (3rd gen). (MOB-15, #61)

## [0.7.14] - 2026-07-04

### Added
- **Magnetometer / compass support in `Mob.Motion`.** Request `:magnetometer`
  in the sensor list and the `{:motion, _}` message additionally carries `mag`
  (calibrated field, µT) and `heading` (degrees from magnetic north). The keys
  are present **exactly when you requested `:magnetometer`**, on both platforms,
  and each is `nil` when there's no reading (device has no magnetometer, or the
  heading hasn't fused yet) — so a compass app matches on `nil` rather than
  hitting a missing key, and accel/gyro-only consumers get the byte-identical
  3-key map with no extra sensor cost. iOS uses the `XMagneticNorthZVertical`
  reference frame (`CMMotionManager`); Android fuses `TYPE_MAGNETIC_FIELD` +
  `TYPE_ROTATION_VECTOR` (`SensorManager`), registered only on request.
  Magnetic north only (true north needs location + declination — layer
  `Mob.Location`). Device-verified on moto g + iPhone SE. (MOB-6, #59)

## [0.7.13] - 2026-07-02

### Documentation
- **Clarified the tag-composite warning and `Mob.Component` vs `Mob.Composite`
  in the Components guide.** The `~MOB: <Tag> is not in the Mob tag whitelist`
  warning is now documented as expected for a *registered* composite (registration
  is a runtime action the compile-time sigil can't see); an unregistered tag
  rendering nothing is the real failure to look for. A new callout separates
  `Mob.Component` (the existing native-view behaviour, whose `render/1` returns a
  native props map) from `Mob.Composite` (pure-Elixir tag expanders returning a
  `~MOB` tree via `expand/3`), and the planned "sub-component event isolation"
  note no longer reuses the `Mob.Component` name. (#53, #58)

## [0.7.12] - 2026-06-30

### Fixed
- **`~MOB` now raises a clear error when `@foo` is used without `assigns` in
  scope.** The `@foo` → `assigns.foo` shorthand (0.7.11) only works inside a
  `render(assigns)`; used in an ordinary helper function (positional args — the
  idiomatic composite pattern) it compiled to a cryptic "undefined variable
  assigns". The sigil now guards with `Macro.Env.has_var?(caller, {:assigns,
  nil})` (the same check Phoenix's `~H` uses) and raises a `CompileError` naming
  the fix (`{title}` instead of `@title`). Only `@`-using templates trigger it —
  a static `~MOB(<Text text="hi"/>)` in a positional-arg helper still compiles.
  (MOB-5, #56)

### Documentation
- **Worked component-authoring examples in the Components guide.** The
  "Defining your own components" section now carries two complete, runnable
  screens — a function composite and a tag composite — spelling out the
  tag→atom rule and where `on_*` event-target auto-injection applies (a
  composite tag's own props vs a plain widget in its children). The `@assigns`
  section documents that `@foo` only works where `assigns` is in scope and
  steers helpers to positional `{var}`. (#56, #57)

## [0.7.11] - 2026-06-27

### Added
- **LiveView-style authoring in the `~MOB` sigil.** Three HEEx idioms now work
  in templates:
  - `@foo` shorthand — inside any `{...}` expression `@foo` rewrites to
    `assigns.foo` (attribute values, `{expr}` children, and the control
    attributes below), including nested access like `@user.name`.
  - `:if={expr}` — renders an element only when the expression is truthy; a
    falsy `:if` drops the element from its parent's children.
  - `:for={x <- list}` — repeats an element per item and splices into the
    parent. Combined with `:if`, the `:if` becomes a comprehension filter
    (LiveView semantics). `:if`/`:for` require a `{expr}` value; only those two
    control attributes are recognised.
- **`Mob.Socket.update/3` and `assign_new/3`**, mirroring
  `Phoenix.LiveView`. `update/3` applies a function to an existing assign
  (`KeyError` if absent); `assign_new/3` lazily sets an assign only when absent.
- New `guides/components.md` "Control flow" section documents all of the above.
  (#52)

## [0.7.10] - 2026-06-26

### Added
- **`baseline` row alignment on iOS.** A `:row` with `align: "baseline"` now
  maps to SwiftUI's `.lastTextBaseline` instead of silently falling through to
  center. (Android `Row` has no row-level baseline alignment, so it still
  centers there.)

## [0.7.9] - 2026-06-26

### Fixed
- **Non-glass `:box` fill ignored `corner_radius` on iOS.** `mobBoxBackground`
  filled the solid (non-glass) background as a plain rectangle, so only the
  separately-stroked border was rounded while the fill kept square corners
  (visible on solid-color boxes; bordered light cards hid it). Clip the fill to
  the corner shape with `in: shape`, matching the glass branches. Thanks to the
  reporter who diagnosed it.

## [0.7.8] - 2026-06-25

### Added
- **`Mob.Device.open_settings/1`.** Opens an OS settings screen for the app:
  `:app` (the app details / permissions page, both platforms), `:notifications`,
  or `:exact_alarm` (Android special-access screens; iOS falls back to the app
  page). The go-to when a permission was permanently denied and the user must
  re-enable it by hand. An unknown target returns `{:error, :invalid}` without
  touching the NIF. On Android the bridge call is optional, so an app whose
  scaffolded `MobBridge.kt` predates `openSettings` no-ops instead of crashing
  (add `MobBridge.openSettings/1` to wire it up). (#50)

## [0.7.7] - 2026-06-24

### Fixed
- **Boot crash on all apps (regression in 0.7.6).** `device_orientation/0` and
  `device_lock_orientation/1` were added to `mob_nif`'s native NIF tables and
  `-export` in 0.7.6 but not to its `-nifs([])` attribute. `load_nif/2` rejects a
  library that registers a NIF not declared in `-nifs`, so `on_load` failed,
  `mob_nif` was purged, and every app crashed at boot with `{undef, {mob_nif,
  log, 1}}` on the first boot step (iOS and Android). Added the two functions to
  `-nifs([])`. A new source-level test (`test/mob/nif_declaration_test.exs`)
  asserts every NIF in the iOS/Android tables is declared in `-nifs([])`, so this
  class of mismatch — invisible to host tests, since NIFs don't load on the host —
  can't ship again. Upgrade from 0.7.6 immediately.

## [0.7.6] - 2026-06-24

### Added
- **Device orientation: detect + lock (`Mob.Device`).** New `orientation/0`
  query, an `{:mob_device, :orientation_changed, orientation}` event under the
  existing `:display` subscription category, and `lock_orientation/1` /
  `unlock_orientation/0` to force (or release) a specific orientation regardless
  of the OS auto-rotate setting. Values: `:portrait`, `:portrait_upside_down`,
  `:landscape` (either side), `:landscape_left`, `:landscape_right`. Use case: a
  screen that must be landscape (e.g. a wide keyboard) locks on enter, unlocks
  on leave.

  iOS reads the foreground window scene's interface orientation, observes
  `UIDeviceOrientationDidChangeNotification`, and drives rotation via
  `requestGeometryUpdate` (iOS 16+); the lock holds once the app shell's root
  view controller reports `mob_locked_orientation_mask()` from
  `-supportedInterfaceOrientations` (companion shell change). Android locks via
  `MobBridge.orientationLock/1` → `Activity.setRequestedOrientation`, with change
  delivery from `MainActivity.onConfigurationChanged` (companion `mob_new`
  changes). Android `orientation/0` returns the last reported orientation
  (partial, consistent with the other Android device queries).

### Fixed
- **iOS canvas now delivers finger-drag (`on_drag`) — at parity with Android.**
  The SwiftUI `MobCanvasView` rendered draw ops but attached no drag recognizer,
  so a canvas's `on_drag` handle (wired through the NIF to `node.onDrag`) was
  never invoked — continuous finger-drag was dead on iOS, while Android's
  `MobCanvas` had `detectDragGestures`. Added a canvas-scoped
  `DragGesture(minimumDistance: 0)` that calls `node.onDrag` with
  began/dragging/ended phases; the gesture's local-space location is already in
  canvas logical units (the frame is sized to the declared width/height), so no
  rescale is needed. Verified on a physical iPhone (iOS 26.5): a finger-drawing
  screen with a color picker and thickness control routes drags and renders
  strokes correctly.

---

## [0.7.4] - 2026-06-20

### Fixed
- **Tap-handle registry is now double-buffered (Android + iOS) — high-frequency
  events no longer drop during a render.** `clear_taps` reset the handle count
  to 0 and re-registered every handler in tree order, so a drag/scroll firing
  from the UI thread *while* a render rebuilt the table saw a transiently-small
  count and a half-built table and got dropped — worse the later a widget
  registered (e.g. a `Canvas` after a row of `Button`s). `register_tap` now
  builds into the inactive table while readers keep resolving the last committed
  one; `set_root` swaps them atomically under `tap_mutex`. A concurrent event
  always sees a complete table on either side of the swap. No API change.
  Verified on-device (moto, finger-drag canvas).

---

## [0.7.3] - 2026-06-19

### Removed (BREAKING)
- **`Mob.Background` is no longer in core — it moved to the opt-in
  `mob_background` plugin.** Background-execution keep-alive (iOS silent
  AVAudioEngine / Android `dataSync` foreground service) and its
  `background_keep_alive`/`background_stop` NIFs are removed from `:mob_nif`.
  Apps that call `Mob.Background.keep_alive/0` must add
  `{:mob_background, "~> 0.1"}`, enable it in `mob.exs`
  (`config :mob, :plugins, [:mob_background]`), and call
  `MobBackground.keep_alive/0` instead. Most apps never used it; the default is
  now that an app ships **no** foreground service unless it opts in — which is
  also what Google Play wants (an unused `dataSync` FGS is a policy rejection).
  Verified on Android (physical + emulator) and the iOS simulator via
  mob_plugin_demo.

---

## [0.7.2] - 2026-06-19

### Added
- **`Mob.ScreenCase`** — the blessed way to unit-test a `Mob.Screen` in-BEAM,
  with an optional device backend. Provides `mount_screen/3`,
  `render_event`/`render_info`, tree queries (`find`/`find_all`/`text`),
  `assert_renderable/2`, and `navigated_to/1`. On `:beam` it runs in
  milliseconds; the same assertions run against real hardware via `:device`.
  `navigated_to/1` returns the destination module on both backends. (#44)

---

## [0.7.1] - 2026-06-16

### Added
- **Collocated screen templates**: a `Mob.Screen` with a sibling `<name>.mob.heex`
  and no inline `render/1` gets `render/1` compiled from that template
  (`@external_resource`, so editing the template recompiles the screen). An
  inline `render/1` still wins. Opt-in and additive. (#22)
- **`Mob.Files.pick/2` type filtering**: `:types` now limits what the document
  picker offers — extension strings (`"livemd"`), MIME strings (`"application/pdf"`,
  `"text/*"`), semantic atoms (`:images`, `:video`, `:audio`, `:pdf`, `:text`),
  explicit `{:extension|:mime|:uti, value}` tuples, or `:any` (default).
  iOS filters strictly via `UTType` (extensions resolve even for unregistered
  custom types); Android SAF filters by MIME only, so `Mob.Files.accept/2` +
  `matches?/2` enforce the filter on results for consistent cross-platform
  semantics. Backward-compatible — the default `:any` preserves the previous
  "offer everything" behavior. See `decisions/2026-06-16-files-pick-type-filter.md`.

---

## [0.7.0] - 2026-06-12 — the plugin-extraction major (BREAKING)

### Added
- **Pure-Elixir composite components** (`Mob.Composite`): UI kits register tag-name expanders (the manifest `ui_components` `expand:` form, or `Mob.Composite.register/2`) and `<MyTag …/>` expands to built-in widget trees in a new FIRST render pass — fixpoint with a depth guard, crash-isolated. `on_*` props written as bare strings/atoms are auto-injected as `{screen_pid, tag}` (no more threading `self()`). Hot-pushable. See `decisions/2026-06-11-composite-expansion-pass.md`.
- **Route-bound navigation params** (`Mob.Nav.Registry.register/3` + `lookup_route/1`): a registered route can carry a params map merged under push params into `mount/3` — the enabler for data-driven plugins (mob_ash registers `/ash/post` as `{MobAsh.ListScreen, %{resource: …}}`). Screen-manifest entries take an optional `:params`.
- **Style packages, tokens-only tier** (MOB_STYLES.md implemented in part): the runtime manifest carries `styles`/`default_style`; boot applies the default style's theme (`Mob.Plugins.apply_default_style/0`). The five preset themes ship in the `mob_themes` package.
- **Boot-time plugin NIF loading** (`mob_notify_set_screen_pid` seam, `host_requirements` printing, `composites` boot registration) — the plugin-system core wiring landed across this cycle; see MOB_PLUGINS.md.

### Removed (BREAKING — each capability moves to its plugin package)
- `Mob.Camera` → `mob_camera` (the `camera_preview` node stays in core)
- `Mob.Location` → `mob_location`
- `Mob.Notify` → `mob_notify` (delivery plumbing — delegate, push-token forward, launch handoff — stays in core; pairs with the server-side `mob_push`)
- `Mob.Photos` → `mob_photos`
- `Mob.Biometric` → `mob_biometric`
- `Mob.Scanner` → `mob_scanner` (requires `mob_camera` for the `:camera` permission)
- `Mob.Bt` → `mob_bluetooth` (Wave 1)
- Themes `Obsidian`/`ObsidianGlass`/`Citrus`/`Birch`/`Material3` → `mob_themes` (light/dark/adaptive remain the neutral baseline)
No deprecation shims (see plugin_extraction_plan.md for the policy rationale). Migration: add the package dep + activate in `mob.exs`; module names change (`Mob.Camera` → `MobCamera`, `Mob.Theme.Citrus` → `MobThemes.Citrus`, …).

## [0.6.26]

### Added
- **Plugin documentation, shipped with the package.** A "Writing a Plugin" authoring guide (`guides/plugins.md`: scaffold → implement → sign → activate → deploy, per tier, with a worked-examples index) plus the manifest reference (`MOB_PLUGINS.md`) and security/trust doc (`MOB_PLUGIN_SECURITY.md`) are wired into ex_doc/HexDocs (a Plugins extras group + a `Mob.Plugins` module group). The reference now documents **cross-plugin conflict detection** (every guarded shared resource + the completeness guarantee) and the **runtime plugin manifest** + its build-time auto-regen.
- **`Mob.Plugins` runtime hardening.** Notification dispatch is crash-isolated — a handler or predicate that raises is logged and skipped instead of taking down the host screen GenServer (mirrors the lifecycle dispatcher). A malformed settings schema (missing `:default`/`:type`) logs + falls back instead of crashing reads/writes, and `register_screens` rejects a `nil` module/blank route at registration rather than deferring the error to navigation.
- **Custom fonts (app-level + plugin).** mob's `font:` prop (documented but only half-built) now works end-to-end: `mix mob.deploy --native` bundles `priv/fonts/*.ttf|otf` and plugin `assets.fonts` into the platform bundle — iOS into the `.app` + `Info.plist` `UIAppFonts` (feeding SwiftUI `Font.custom`), Android into `res/font/<normalized>` (uncompressed; the renderer loads it by resource id, fixing the previous `Typeface.create` stub that only handled system families). Visually confirmed on Android: a plugin-shipped font renders distinct from the system font.
- **Plugin tiers 3 (multi-screen) and 4 (embedded sub-app).** See `decisions/2026-06-06-plugin-tiers-3-4.md`. Both are pure-Elixir and runtime-wired off a generated runtime manifest (`priv/generated/mob_plugins.exs`, written by `mix mob.regen_plugin_manifest`) that the new `Mob.Plugins` module reads at boot. **Tier 3:** plugins ship whole `Mob.Screen` modules (static `:screens` or spec-v2 `:screens_generator` codegen run under the host-config audit), registered as navigable routes in `Mob.Nav.Registry`; plus `:migrations` (build-copied into the host migrations dir, namespaced + version-preserving, run by the host's `Ecto.Migrator`) and `:assets`. **Tier 4:** `:lifecycle` (`on_start` + supervised children + `on_resume`/`on_background` via `Mob.Plugins.Supervisor`/`Lifecycle` and `Mob.Device`), `:settings` (`Mob.Plugins.get_setting/2`/`put_setting/3` on `Mob.State`, schema-validated, with an `editor_screen`), and `:notifications` (`Mob.Plugins.dispatch_notification/1` first-match routing). Device-verified on a physical iPhone (SE) and Android (Moto G): static + generated screens register, a plugin migration creates its table on device, and tier-4 on_start / supervised worker / settings / notification routing all work. `Mob.Plugins.boot` captures the host OTP app name at compile time via `use Mob.App` (a mob release boots without `Application.start`, so `Application.get_application/1` is nil at runtime).

### Changed
- **Location fully extracted to the standalone `mob_location` plugin (Wave 2).** See `plugin_extraction_plan.md` and `decisions/2026-06-05-mob-location-extraction.md`. `Mob.Location` (`get_once`/`start`/`stop`), the iOS `CLLocationManager` NIFs + delegates, the Android `FusedLocationProviderClient` Zig NIF + `mob_deliver_location`, and the hardcoded `"location"` branch of `nif_request_permission` are removed from core (`lib/mob/location.ex`, `ios/mob_nif.m`, `android/jni/mob_nif.zig`, `src/mob_nif.erl`). `mob_location` is a cross-platform tier-1 plugin: it ships an Objective-C iOS NIF (`lang: :objc`) and an Android Zig NIF (`lang: :zig`, via `MobLocationBridge`), registers the `:location` capability through the extensible permission registry (iOS `mob_register_permission_handler`, Android `MobPermissionProvider`), and declares its Android permissions + iOS plist key + `play-services-location` + `CoreLocation` framework in its manifest (mob_dev merges these into the host at build time). **Breaking:** core no longer provides any location surface and there is intentionally no compatibility shim. Apps that used `Mob.Location.*` should add `{:mob_location, "~> 0.1"}` (or `path:`/`github:`) and call `MobLocation.*`. The same location surface was removed from the `mob_new` generated-app templates. Device-verified on a physical iPhone (SE) and Android (Moto G) both before and after the core strip — `MobLocation` round-trips real fixes through the plugin alone, and `:mob_nif.location_get_once/0` now raises `UndefinedFunctionError`.

### Fixed
- **iOS: stop capping the literal super-carrier at 10 MB.** `mob_beam.m` appended a hardcoded `-MIscs 10` after the configured flags; since allocator flags are last-wins, it silently overrode the 0.6.24 `-MIscs 128` default (and any `mob_beam_flags` override), so the literal area was always 10 MB. A large app (e.g. embedded Livebook) plus a notebook's `Mix.install` filled it and the VM aborted with `literal_alloc: Cannot allocate ...`. Removed the hardcoded cap; the `-MIscs 128` default now takes effect (iOS accepts a 128 MB reservation). Verified on a physical iPhone: `emu_args` shows a single `-MIscs 128` and `Mix.install` returns `:ok`.

## [0.6.25]

### Added
- **"Open with" — receive a file another app opens into yours.** New `Mob.Files.take_opened_document/0` returns `%{path, name, mime, size}` (or `:none`) for a file handed to the app (e.g. a notebook emailed and tapped), parallel to `Mob.Files.pick/2`'s `{:files, :picked, …}`. Call it from your root screen's `mount/3`; a file opened while already running arrives as `{:files, :opened, item}` (iOS). New NIF `take_opened_document` plus C-export `mob_set_opened_document` on both platforms (iOS `application:openURL:options:` → `mob_handle_opened_url`; Android `MainActivity` reads the ACTION_VIEW/SEND intent → `MobBridge.setOpenedDocument`). The app declares the document type (iOS `CFBundleDocumentTypes`, Android `<intent-filter>`) and forwards the open. Verified end-to-end: a `.livemd` opened into the embedded-Livebook app opens as a notebook on a physical iPhone and a physical Android (Moto G).

## [0.6.24]

### Fixed
- **iOS: enlarge the BEAM literal super-carrier to 128 MB (`-MIscs 128` default flag).** iOS can't reserve the OTP default 1 GB literal virtual area and falls back to ~10 MB. A large app such as an embedded Livebook plus a notebook's `Mix.install` fills that 10 MB and the VM aborts with `literal_alloc: Cannot allocate N bytes (of type "literal")`. The iOS native launcher's default flags now request a 128 MB literal carrier — a virtual `MAP_NORESERVE` reservation (commits physical only on use) that iOS accepts where 1 GB fails. Apps no longer need a per-app `beam_flags:` override for this. iOS-only; Android keeps its normal large carrier. A runtime `mob_beam_flags` override still wins. Verified on a physical iPhone: embedded Livebook serves and `Mix.install([{:short_uuid, "~> 0.1"}])` returns `:ok`.

## [0.6.23]

### Added
- **Element positions without a screenshot.** `element_frames/0` NIF surfaced as `Mob.Test.element_frames/1` (`%{id => {x,y,w,h}}`), `frame/2`, and `tap_id/2` (drive by id at real coordinates). Any rendered node given an `:id` reports its live on-screen frame (logical points iOS / dp Android) to a registry the agent reads over dist — a compact structured map instead of image bytes, with no accessibility activation. The renderer also sets the `:id` as the element's accessibility identifier (iOS `accessibilityIdentifier`, Android Compose `testTag`), so the same tags are visible to XCUITest/Espresso. Opt-in per element: untagged nodes cost nothing (the tracking modifier only attaches when an `:id` is present). iOS records the full element frame via a `GeometryReader` background; Android via `Modifier.onGloballyPositioned`. Verified on iOS sim, Android device, and a physical iPhone. The Android Kotlin side lives in the `mob_new` `MobBridge.kt.eex` template.
- **In-process screenshot + scroll control over dist (no adb/xcrun).** Three test-harness NIFs (`screenshot/3`, `scroll_info/1`, `scroll_to/3`) surfaced as `Mob.Test.screenshot/2`, `scroll_info/2`, `scroll_to/4`, and `screenshot_tour/3`. A remotely-connected agent gets pixels and deterministic scroll entirely over Erlang distribution — the capability Sloppy Joe and WireTap need to drive a device an agent can only reach over dist. Capture is in-process (iOS `UIGraphicsImageRenderer` + `drawViewHierarchy`; Android `PixelCopy` against the activity window). Scroll views are addressed by their `:id` prop; `scroll_info` reports `kind: :pixel` (iOS `UIScrollView`, Android `verticalScroll`) or `:index` (Android `LazyColumn`, where y is an item index and viewport is the visible-item count). Captures the app's own surface only — `FLAG_SECURE`/secure fields render blank, and a backgrounded app returns `{:error, :no_window}`. The Android Kotlin side (`screenshot`/`scrollInfo`/`scrollTo`) lives in the `mob_new` `MobBridge.kt.eex` template; existing apps pick it up on regeneration. Debug-only (iOS `#if !MOB_RELEASE`). See `decisions/2026-05-29-bridge-nif-screenshot-scroll.md`.

### Changed
- **`Mob.Bt` fully extracted to the standalone `mob_bluetooth` plugin (Wave 1 complete).** See `plugin_extraction_plan.md`. Session A moved the Elixir wrappers (`Mob.Bt`, `Mob.Bt.Hfp`, `Mob.Bt.Hid`, `Mob.Bt.Spp`) out of core; Session B now removes the native side too — the Bluetooth Zig NIF from `android/jni/mob_nif.zig` and the iOS unsupported-stubs from `ios/mob_nif.m`. `mob_bluetooth` is now a tier-1 plugin: it ships its own Zig NIF, JNI thunks, and `MobBluetoothBridge` Kotlin, and declares its Android permissions + iOS plist keys in its manifest (mob_dev merges these into the host app at build time). **Breaking:** core no longer provides any Bluetooth surface and there is intentionally no compatibility shim. Apps that used `Mob.Bt.*` should add `{:mob_bluetooth, "~> 0.1"}` (or `path:`/`github:`) and rename references to `MobBluetooth.*`. HID input and SCO PCM streaming were never implemented and are not part of the plugin (HID is platform-blocked on Android; see the plugin's docs).

## [0.6.22]

### Added
- **`Mob.Certs`** — load CA certificates from a PEM bundle into Erlang's `:public_key` cacert store. Android's system trust store lives behind a Java API that `:public_key.cacerts_load/0` (no-arg) can't reach, so the first TLS call from Req / Mint / Finch crashes with `no_cacerts_found` (or `FunctionClauseError` in some OTP versions). Apps bundle a PEM (conventional source: copy `castore`'s `cacerts.pem` into `priv/` at build time) and call `Mob.Certs.load_cacerts!(Application.app_dir(:my_app, "priv/cacerts.pem"))` once at boot. iOS and the Android emulator aren't affected; calling unconditionally is harmless there. Verified end-to-end on a Moto G Power 5G 2024 (Android 14): `Mix.install([{:req, "~> 0.5"}])` then `Req.get!("https://geocoding-api.open-meteo.com/v1/search?name=Vancouver")` returns `200`.
- **`mob_beam.zig` exports `MOB_NATIVE_LIB_DIR`** before BEAM start — the absolute path of the app's nativeLibraryDir, which the APK install hash makes unpredictable at compile time. Apps that bundle runtime binaries (escript, rebar3, etc.) as `lib*.so` need this to set `MIX_REBAR3` and locate the bundled escripts.
- **Optional ERTS-extras symlinks (`escript` / `erlexec` / `erl` / `beam.smp`)** in `mob_beam.zig`. Silent-skips when the lib isn't in nativeLibDir, so non-opting-in apps see no behaviour change. Apps that drop `lib<name>.so` into `android/app/src/main/jniLibs/<abi>/` get a working `BINDIR/<name>` — enough for runtime `Mix.install` of rebar3-built deps (telemetry, jose, jiffy, …) to bootstrap a fresh VM. `erl` and `erlexec` both target the same `liberlexec.so` because they are the same binary (erlexec doesn't switch on `argv[0]`).

### Changed
- **`extra_applications: [:logger, :public_key]`** — Elixir 1.19+ strips unused OTP applications from the code path; `Mob.Certs` calls `:public_key.cacerts_load/1` at runtime, so its `.beam` must be in the path even though mob doesn't *start* `:public_key` itself.

### Fixed
- **`mix.exs`** — collapsed duplicate `before_closing_body_tag/1` clauses introduced in 0.6.20. The mermaid clause's `_` catchall shadowed an older language-elixir highlighter clause, leaving it as dead code (and emitting compile warnings). The unified clause emits both scripts; the duplicate `docs/0` keyword entry was removed.

### Docs
- `common_fixes.md` — new section documenting the Android cacerts symptom (`no_cacerts_found` / `FunctionClauseError`) and the load-PEM-at-boot fix; also the bundled-OTP-extras pattern (wrapper script, rebar3 module-name derivation, `$ROOTDIR/bin/*.boot` materialization) for apps that opt into runtime rebar3.

## [0.6.21]

### Added
- **`Mob.DNS.resolve/1` now works on Android.** `nif_resolve_ipv4` (`android/jni/mob_nif.zig`) calls Bionic's `getaddrinfo` in-process and seeds `:inet_db`'s `:file` table, mirroring the iOS NIF added in #32. Physical Android devices return `:nxdomain` from BEAM's default DNS path (forking `inet_gethost` as a port program) even when the same app's in-process HTTPS stack resolves the hostname fine — the emulator masks this. Verified end-to-end on a Moto G Power 5G 2024 (Android 14): `Mob.DNS.resolve("repo.hex.pm")` returns the right IP, `:inet.getaddr/2` then succeeds via the seeded entry, and `Mix.install([{:dep, "~> ..."}])` from a notebook setup cell resolves, fetches, and compiles on-device. Bionic `addrinfo` / `sockaddr_in` / `getaddrinfo` / `freeaddrinfo` / `EAI_*` bindings added to `android/jni/mob_zig.zig`. Suspected root cause is `libnetd_client.so`'s netd routing not surviving execve; the NIF sidesteps it by running in the app's own process.

### Changed
- **`Mob.DNS` moduledoc** — dropped the "Android isn't affected" claim. Added a background-app caveat: Android App Standby blocks *all* outbound network from a backgrounded mob app (TCP-by-IP, not just DNS — surfaces as `:closed` / `:timeout` on any socket attempt). Fix is a foreground service or keep the app foregrounded; not a mob bug.

### Docs
- `common_fixes.md` — new section documenting the `:nxdomain` symptom on physical Android, the foreground-app caveat, and the fix.

## [0.6.18]

### Changed
- **`RUSTLER_NIF_LIB_PATH` → `RUSTLER_BEAM_LIBRARY_PATH`** in `mob_beam.zig`'s host setenv block. Matches the env var name filmor chose for the alternative upstream rustler PR (rusterlium/rustler#733), which is what'll land upstream instead of our #726. End-to-end tested on physical arm64 Android with filmor's branch: Mob sets the env var → rustler reads it → Rust NIF resolves and executes. Mob users on rustler 0.37 Hex release (no patch) see no change; users on the GenericJam fork OR on whatever rustler version eventually ships #733 get matching behaviour.

## [0.6.17]

### Added
- **`Mob.Audio.play_at/4`** — sample-accurate scheduled audio playback. Takes an absolute local wall-clock target (`System.system_time(:millisecond)` ms-since-epoch) and hands it to the audio *hardware* clock for firing, rather than waking the BEAM via `Process.send_after`. The hardware-clock path eliminates timer-wheel + scheduler jitter from the end-to-end sync error, leaving per-device first-sample latency (~30–80 ms, calibratable) as the dominant remaining term. iOS only in this release; Android still falls through to the existing `MediaPlayer` path (port to AAudio is pending).
- iOS: `nif_audio_play_at(Path, OptsJson, AtWallMs)` backed by a dedicated `AVAudioEngine` + `AVAudioPlayerNode`. The wall-time target is converted to an `AVAudioTime` `hostTime` via `mach_absolute_time` + `mach_timebase_info`, then handed to `-[AVAudioPlayerNode scheduleBuffer:atTime:options:completionHandler:]`. Past targets schedule ASAP. Multiple `play_at` calls accumulate on the player's timeline — use `audio_stop_playback` to flush.
- `audio_set_volume` and `audio_stop_playback` now also reach the scheduled-engine player so cross-API mixing behaves sanely.

### Use case
- Distributed orchestra / multi-device musical performance where every phone must start the same sample at the same wall-clock instant. Pair with an NTP-style server-clock-sync helper on the caller side; this API takes the converted local-clock target.

## [0.6.16]

### Added
- **`mob_beam.zig` exports `RUSTLER_NIF_LIB_PATH` before BEAM start.** Calls `dladdr(&mob_start_beam)` to discover the absolute path of the host `.so` (e.g. `lib<app>.so`) and `setenv()`s it as `RUSTLER_NIF_LIB_PATH`. Pairs with the matching upstream rustler change (rusterlium/rustler#726): rustler's `DlsymNifFiller::new()` on Android reads the env var first, falls back to its existing dladdr-self probe when unset. End result: rustler-based Rust NIFs statically linked into Mob's main `.so` now resolve `enif_*` symbols correctly on Bionic without any per-app patching. Existing rustler users on Android who *don't* run inside Mob see no change — the dladdr fallback covers them.
- **`mob_zig.zig` exposes `dladdr` + `DlInfo`** to other Zig consumers under `jni.dladdr` / `jni.DlInfo`. Hand-declared to match the libc/Bionic surface; same hand-declared FFI policy as the rest of `mob_zig.zig` (we don't use `@cImport` here).

### Notes
- The setenv runs unconditionally — even apps that don't ship a rustler NIF get the env var set. Harmless. The env var only affects rustler's own startup logic when a rustler-built NIF loads.
- Verified end-to-end on a physical arm64 Android device (moto g power 2021): host sets path → rustler reads env var → `dlopen(path, RTLD_NOW | RTLD_NOLOAD)` → `dlsym` all `enif_*` exports → Rust NIF `greet/0` executes and returns `"Hello from Rust!"` to BEAM.

## [0.6.15]

### Added
- `text_field` now accepts a `secure: true` prop. iOS renders the field
  as a SwiftUI `SecureField` (masked input) instead of the plain
  `TextField`. The prop flows through the existing renderer
  passthrough; cleartext still reaches the BEAM via `on_change` so apps
  can hash/store the value as normal. Android consumes the same prop
  via `PasswordVisualTransformation` once `mob_new`'s `MobBridge.kt.eex`
  template is updated in a companion PR — until then the prop is a
  graceful no-op on Android (renders as a regular field), no breakage.

  Reveal-toggle ("eye" button) is intentionally deferred — its
  interaction with SwiftUI focus retention requires a `ZStack`-and-opacity
  rebuild of `MobTextField` and warrants its own change.

### Fixed
- iOS: `Mob.App.start/0` now switches `:inet_db` to file-only lookup and seeds `localhost` before any user code runs — BEAM's default `:native` lookup tries to `execve` the `inet_gethost` port program, which the iOS sandbox refuses, crashing the first `Node.connect` / `:erpc.call` / `gen_tcp.connect/3` with `:badarg`. Apps no longer need to set the lookup chain themselves; `Mob.DNS.configure_pure_beam/1` still composes on top for outbound DNS. See `guides/dns_on_ios.md`.
- iOS: `Column` now honours `fill_height: true`. The `.column` case in `MobRootView` only set `maxWidth`, so a `Column` with `fill_height: true` would collapse to its children's natural height — breaking the canonical `<Column fill_width fill_height>` header/flex/footer pattern. Now sets `maxHeight: .infinity` when the prop is set and switches alignment to `.topLeading` so children anchor at the top when the column flexes. Default (no `fill_height`) behavior is unchanged.

### Docs
- Plugin system design corpus: `MOB_PLUGINS.md` (capability-plugin manifest, tiers 0-4, spec-v2 code-generated plugins), `MOB_STYLES.md` (style preset system, namespaced cherry-pick, stable per-primitive prop contract), `MOB_PLUGIN_SECURITY.md` (three-layer trust model, dev-mode escape hatches, `:acknowledge_unsafe_plugins`), `plugin_extraction_plan.md` (Phase 0 → Phase 3 + risk register + kickoff checklist). Locks scope to Elixir-first, BEAM-native, Gen-AI-enabled; parks full-language non-BEAM frontends at speculative `plugin_spec_version: 3`. Companion `agent_briefs/rustler_env_var_test.md` covers filmor's env-var-based fix in `rusterlium/rustler#726`.

## [0.6.14]

### Added
- **`:mob_nif.set_theme/1` — push resolved theme palette to native.** Lets a Compose `MaterialTheme` wrapper follow runtime `Mob.Theme.set(...)` calls instead of being baked into MainActivity at compile time. Otherwise Material 3 system chrome (NavigationBar, Button, etc.) stays at the default light scheme while the BEAM-side primitives switch to whatever theme is active — a visible mismatch when an app uses Obsidian / ObsidianGlass.
- **`Mob.Theme.resolved_palette/1`** — exposes the "semantic token → theme map → palette → ARGB int" resolution path that the renderer uses internally. The native side gets concrete integers it can hand to `Color(...)` directly.

### Notes
- iOS implements the NIF as a no-op for symmetry — SwiftUI in `MobRootView.swift` renders every surface via mob primitives with explicit color props, so there's no system chrome that needs the push.
- The Android `MobBridge.setTheme(String)` Java hook is looked up via `cacheOptional`, so older templates that predate this load fine; the NIF just returns `:ok` without dispatching when the method isn't on the bridge.
- The mob_new generator templates that wire `MaterialTheme` ↔ `setTheme` in newly-generated apps will follow in a separate release; existing apps adopt manually (a `MutableState` in MobBridge.kt + `MaterialTheme(colorScheme = …)` wrap in MainActivity.kt).

## [0.6.13]

### Changed
- **Liquid Glass uses `Glass.clear` instead of `Glass.regular`.** On dark surfaces with little behind a card to refract, `.regular` reads as a frosted plate rather than glass. `.clear` is the right variant for the floating-card look the theme is meant to evoke — what's beneath shows through, the card looks like it's hovering. Only affects iOS 26+ (the `.ultraThinMaterial` fallback for older iOS is unchanged).

## [0.6.12]

### Added
- **`Mob.Theme` — `glass` flag for translucent surfaces.** New `glass: false` field on the theme struct. When set, `Mob.Renderer` tags every `Box` node that has a `background:` with `glass: true`, and the iOS side swaps the solid fill for `.glassEffect(.regular, in: shape)` on iOS 26+ (real Liquid Glass) or `.ultraThinMaterial` on iOS 17–25 (closest fallback that ships in older SDKs). Other nodes pass through untouched. Opt in via a preset or by passing `glass: true` to `Mob.Theme.build/1`.
- **`Mob.Theme.ObsidianGlass`** — Obsidian palette + `glass: true` for the common "make the whole app glassy" case. Switch at runtime with `Mob.Theme.set(Mob.Theme.ObsidianGlass)`; revert with `Mob.Theme.set(Mob.Theme.Obsidian)`.
- **`Mob.Theme.flags_map/1`** — companion to `color_map/1` / `spacing_map/1` / `radius_map/1`. Returns `%{glass: bool}` for now; future flag-style toggles will land here.

### Notes
- Android receives the flag but ignores it for now — Compose Material 3 doesn't ship a first-class glassy surface yet; boxes fall back to solid. Compose-side support is a follow-up.

## [0.6.11]

### Fixed
- **`~MOB` sigil no longer double-encodes non-ASCII bytes in template source.** The NimbleParsec parser used `ascii_string/2` for string attribute values (`text="..."`) and brace content (`text={...}`); its `integer`-typed body re-encoded each source byte ≥128 as a Latin-1 codepoint then UTF-8. Net effect: `–` (E2 80 93) emerged as `Â`+pad+`O` (C3 A2 C2 80 C2 93) — mojibake on screen. Swapped both call sites to `utf8_string/2`, which matches by codepoint and round-trips multi-byte sequences (em-dash, en-dash, middle dot, smart quotes, accents, emoji) byte-for-byte. Workaround that's now unnecessary: binding the non-ASCII string to a variable outside the sigil and referencing it via `text={var}`.

## [0.6.10]

### Added
- **iOS BEAM startup honours `MOB_NODE_SUFFIX` env var.** The simulator branch already auto-derived a unique node-name suffix from `SIMULATOR_UDID` so concurrent sims didn't collide in Mac's EPMD, but there was no manual override path — the Android-side `MOB_NODE_SUFFIX` convention was iOS-blind. Now both branches (simulator + physical device) read `MOB_NODE_SUFFIX` with priority: explicit env → SIMULATOR_UDID-derived (sim only) → none. Pairs with `mob_dev 0.5.10`'s `mix mob.deploy --node-suffix X` flag (forwarded to simctl via the `SIMCTL_CHILD_*` mechanism).
- Resolves the `Protocol 'inet_tcp': register/listen error: no_reg_reply_from_epmd` symptom seen when running multiple iOS sims of the same app concurrently for visual-comparison work (e.g. cross-platform theme parity).

## [0.6.9]

### Fixed
- **CI pipeline unblocked.** The 0.6.8 push failed two CI gates and never
  reached Hex; this release ships the same code with the gates green:
  - `android/jni/mob_beam.h` reformatted to satisfy `xcrun clang-format
    --dry-run -Werror` (the camera-frame delivery declaration was split
    across three lines in a style clang-format wanted on two).
  - `decimal` bumped 2.4.0 → 3.1.0 (transitive via `ecto_sqlite3` /
    `jason`) to clear advisory **GHSA-rhv4-8758-jx7v** — unbounded
    exponent in `Decimal.new/1` enables an unauthenticated DoS, affects
    `< 3.0.0`. `jason` bumped 1.4.4 → 1.4.5 since older Jason capped
    `decimal` to `~> 1.0 or ~> 2.0`.

No source-level changes since 0.6.8 — same `Mob.Camera.start_frame_stream/2`
Android implementation and `Mob.Canvas` viewport docs, now actually on Hex.

## [0.6.8]

### Added
- **`Mob.Camera.start_frame_stream/2` now works on Android.** The
  Camera2 + CameraX `ImageAnalysis` use case is wired through to BEAM
  as `{:camera, :frame, %{bytes, width, height, format, timestamp_ms,
  dropped}}` messages. Previously this NIF returned `:unsupported` on
  Android — iOS-only. The Android implementation supports the same
  `format: :rgb_f32` the iOS side does (`:bgra_u8` planned for a
  follow-up).
- **`Mob.Canvas` moduledoc** documents the viewport-scaling contract:
  the `width`/`height` props are logical viewport units, NOT pixels.
  The renderer scales draw-op coordinates against the actual on-screen
  pixel size. New tests in `test/mob/canvas_test.exs` pin the
  contract so future readers don't regress to interpreting them as
  raw pixels.

### Notes
- Combined with `mob_dev 0.5.9`'s `mix mob.enable tflite` and the
  `nx_tflite_mob 0.0.3` Hex package, the cross-platform live YOLO
  demo (`mob_yolo_demo`) now runs end-to-end with only Hex deps.
  Measured perf: 24 ms iPhone SE A15 via Core ML → ANE; 75–117 ms
  Moto G Power 5G (Dimensity / BXM-8-256) via NNAPI / `mtk-gpu_shim`.
## [0.6.7]

### Added
- `guides/mobile_surface_matrix.md` — comprehensive audit of mob's mobile capability surface vs. React Native + Expo SDK reference. Tables across UI components, gestures/input, device/system, storage, camera/audio, connectivity, sensors, location, notifications, background tasks, auth/payment, ML/Vision, maps, accessibility, iOS-only, Android-only, plus an "architecturally not present" section. Per-row status (✅ / 🟡 / ❌ / ⛔) with iOS + Android indicators. Hand-maintained from inspection of `lib/mob/` and `src/mob_nif.erl`. Sets realistic expectations and surfaces plugin candidates.
- README link + hexdocs entry so the matrix is discoverable for new users.
- `RELEASE.md` "Tests + docs for new functionality" section now includes a `mix docs` preview step and clarifies that hexdocs publishing is automatic via `mix hex.publish` (rides along from the previously-unreleased doc improvement).
- `MOB_PLUGINS.md` — plugin manifest schema spec covering five plugin tiers (pure Elixir helper through embedded sub-app), worked examples per tier, install + activation flow, schema reference, validation rules, hot-push compatibility table, plugin_spec_version forward-compat. References from the matrix's ❌ rows as plugin candidates.

## [0.6.6]

### Added
- `RELEASE.md` — canonical release-process documentation covering the
  mix.exs-driven trigger model, the patch-bump-default-with-mandatory-
  permission rule, CHANGELOG conventions, when a bump is warranted (new
  functionality, bug fixes, doc improvements, dep bumps) vs. when it
  isn't (CI tweaks, hook changes, internal refactors), the
  tests-and-docs-with-new-functionality non-negotiables, and the
  per-step idempotency of `release.yml`. Linked from `mob_dev` and
  `mob_new` CLAUDE.md by URL so the canonical process is one file.
- `.githooks/pre-push` — committed pre-push hook that runs the cheap
  preflight (format + credo + warnings-as-errors) on every push and
  the full release preflight (test suite + `mob.security_scan` where
  present) only when `mix.exs` changed. Activate per-clone with
  `git config core.hooksPath .githooks`.
- `CLAUDE.md` "Release flow" section linking to the new docs.

## [0.6.5]

### Fixed
- HexDocs source links pointed at the non-existent `main` branch — corrected to `master` so each `</>` glyph next to a heading now opens the actual source file in the GitHub repo.
- `mob_nif.zig` called the variadic `enif_make_list/2` (not exposed in `mob_erts.zig`) from the BT paired-list finisher; the Android arm64 build failed at link. Switched to the non-variadic `enif_make_list_from_array(env, &empty, 0)`.

### Added
- `.github/workflows/test.yml` — runs `mix test`, `mix format --check-formatted`, `mix credo --strict`, `mix erlfmt --check src/`, `xcrun clang-format`, `swiftlint`, and `mix deps.audit` on push to master and on every PR.
- `.github/workflows/release.yml` — on tag push, creates a GitHub Release whose body is the matching `## [X.Y.Z]` section from this changelog (falls back to auto-generated commit notes if the tag has no section).
- `PLAN.md` — three-layer CI + integration-test plan covering the gap between unit tests and on-device verification.

## [0.6.4]

### Added
- `Mob.GpuView` / `Mob.UI.gpu_view/1` — Metal fragment-shader surface on iOS. Host owns the vertex shader (full-screen quad with `v_uv`); user supplies an MSL fragment shader plus a list of uniforms packed at natural alignment into fragment-buffer slot 0. SwiftUI `MobGpuView` wraps an `MTKView` with a hash-keyed shader cache and a translucent red overlay for compile errors. iOS-only in this release; the Android GLES 3.0 backend ships in mob_new 0.3.1.
- `<GpuView>` tag whitelisted for both `priv/tags/ios.txt` and `priv/tags/android.txt`.

## [0.6.3]

### Fixed
- iOS camera sensor delivered frames in landscape-right by default — `Mob.Camera.start_frame_stream/2` was feeding 90°-rotated pixels to ML models, dropping classification accuracy enough that a jar appeared as "laptop 24%" instead of "cup 96%". `AVCaptureConnection.videoRotationAngle = 90` (iOS 17+) / `videoOrientation = .portrait` (older) is now set on both the preview layer and the data-output connection, so what the user sees and what the model sees are the same upright frame.

## [0.6.2]

### Added
- `Mob.Camera.start_frame_stream/2` and `stop_frame_stream/1` — push-driven per-frame delivery as `{:camera, :frame, %{bytes, width, height, format, timestamp_ms, dropped}}`. Defaults to 640×640 `rgb_f32` for direct Nx hand-off; caller-overridable width/height/format/facing and a software `throttle_ms` gate.

### Changed
- iOS camera now uses a single shared `AVCaptureSession` for preview and frame stream. The previous two-session design silently dropped frames because iOS allows only one active session per physical camera.

## [0.6.1] and earlier

Earlier releases predate this changelog; consult the [tag list](https://github.com/genericjam/mob/tags) and the per-tag commit messages for history.
