All notable changes to mob are documented here.
Format: Keep a Changelog. Versioning: SemVer.
Full module documentation: hexdocs.pm/mob.
[Unreleased]
[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). Seedecisions/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/1andnative_summary/1(MOB-126).Mob.RenderStatsmeasures seven stages and every one is on the BEAM side of the boundary:set_root_uscloses whennif_set_rootreturns, 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_RELEASEguard 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. Seedecisions/2026-09-03-measure-the-native-half-of-a-frame.md.Mob.RenderStats— per-frame render instrumentation (MOB-125). Records the user'srender/1, tree expansion, component reconcile, the renderer's prepare walk,register_tap,:json.encode, andset_rootas seen from the BEAM, plus node count,register_tapcall count and payload bytes. Off by default behind a:persistent_termflag; readable over dist withMob.RenderStats.summary/0, which reports p50/p95/max with the sample sizenper stage.verify_taps/1enables an opt-in second walk that cross-checks the tap count. Seedecisions/2026-09-01-render-instrumentation.md, including whytotal_usmust not be compared against a frame budget.
Performance
:scrollcan build its content lazily, withlazy: true(MOB-128). A column that is the direct content of a vertical scroll usesLazyVStackrather thanVStack, so only the rows on screen are built. Opt-in: rows below the fold are never built, soMob.Test.element_frames/tap_idcannot address them andscroll_to(:bottom)under-scrolls, exactly as forlazy_list. Arowunder 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
mobalone 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 (inmob_new) measures a 500-row screen going from 498.9 ms to 115.8 ms of main-thread work per frame. Seedecisions/2026-09-02-lazy-scroll-on-ios.md.iOS
set_rootis 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_root7625 → 4040 µs, whole frame 13002 → 9403 µs. Purely native-internal; no wire-format change.register_tapno longer logs once per exhausted call (MOB-133). On a screen with more thanMAX_TAP_HANDLES(256) interactive elements, the exhaustion path calledNSLogsynchronously 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 fromset_root, takingregister_tapfrom 13004 µs to 81 µs.clear_tapsfrees only the slots that were used, instead of walking all 256 every frame.
Fixed
ErlNifEnvleak on the rescued render path (MOB-133). Boundingclear_tapsby a high-water mark that onlyset_rootwrote leaked oneErlNifEnvper tap, per frame, whenever a render raised betweenclear_tapsandset_root— a pathMob.Sender.commit/1deliberately rescues, so it accumulated silently.register_tapnow maintains the mark. Seedecisions/2026-09-02-register-tap-owns-the-table-high-water-mark.md.tap_exhausted_countno longer leaks across frames. It was reset only insideset_root's reporting branch, so a frame that overflowed and then failed carried its count into the next frame's report. Reset inclear_tapsnow. The iOS increment also moved inside the tap mutex, matching Zig.Mob.Senderno 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/1andMob.Sender.render/5cannot 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,leadingandtrailingare accepted and stored but not yet acted on by either platform. Seedecisions/2026-09-02-throttle-config-targets-the-building-table.mdanddecisions/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/2may skip; every other paint path is unconditional. Skips are recorded throughMob.RenderStatswithreason: :unchangedrather than vanishing, so a screen that stops updating stays diagnosable. Seedecisions/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'sbody, and unlikeUIImage(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_reachedfires on arrival at the end, not on content replacement (MOB-141). Keying children on the author's:idgave every row a new identity when a list's contents were replaced, so the last row'sonAppearran 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
.onAppearwould have. Separating "new content, user is at the end" from "new content, user never scrolled" needs scroll position rather than content identity. Writeon_end_reachedhandlers 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
:idwhen 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, soid: 1behaves asid: "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
NavigationBarstill iterates tabs positionally, so reordering or inserting a tab moves per-tab state on Android and not on iOS. Seedecisions/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
badargrather than silently succeeding. (#114)
[0.7.37] - 2026-08-30
Added
- Directional tab switches with first-mount parameters.
Mob.Socket.switch_tab/3andMob.Test.switch_tab/3taketransition: :push | :pop | :resetandmount_paramsdelivered 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/4acceptsscope: :all(mirrored byMob.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
heightwithout a fixedwidth.fixedHeightwas only applied inside the fixed-width branch, so a width-lessheight: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.Themecaches themob_nifavailability probe in:persistent_term(first probe serialized; re-probed when the module loads), so host test runs lose the per-callFailed to load NIF librarynoise 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/3returns:okonly 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. NewMob.Test.sample_color/2samples 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
weightin 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:noneframe ahead of the animated one — previously a lost or doubled transition. Makesreset_to/4's directional:push/:popwipes reliable for screens that load data on mount. (#103) - Cold-start launch notifications are no longer dropped (Android + iOS).
mob_set_launch_notification(Android) andmob_set_launch_notification_json(iOS) bailed out when called beforenif_loadhad 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 viaonNewIntent/ the running delegate) was unaffected. Both setters now store before the mutex exists, the same pre-mutex patternmob_set_opened_documenthas always used — safe because nothing reads the global untiltake_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_barwidget 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}inhandle_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/4takestransition: :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/4takes the same option, so the behaviour can be driven on a device.
Changed
- The
:resetnavigation action carries a fourth element, the transition.Mob.ScreenCase.navigated_to/1and the router understand both shapes; the three-element form still arrives fromMob.Test.reset_to/3and from any socket built before a hot code push. - An unrecognised transition now raises
ArgumentErroratMob.Socket.reset_to/4rather than reaching the platform, which accepts any atom and silently falls back to no animation.:noneis 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_eventno 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: Norpersist: true) recover throughload_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/1anddrawer/1have been public API inMob.App's own moduledoc while the runtime behind them could hold exactly one history, andMob.Socket.switch_tab/2did 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, matchingUITabBarController. 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/1after any fire-and-forget call (tap/2,back/1,send_message/2) before reading the native side withview_tree/1,screenshot/2,tap_id/2orelement_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 writingon_tap: {self(), :save}or starting a task from a screen.- Rendering is serialized through
Mob.Senderand 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_infowith 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/1anddrawer/1are now backed by the runtime andswitch_tab/2works, but no tab bar or drawer chrome is drawn — switching is programmatic for now. reset_to/2does not re-derive which stack its destination belongs to (MOB-115); parked screens missterminate/2and 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/2accepts[:content]and[{:content, max_height: n}]alongside[:medium, :large]. A content detent is exclusive of the built-ins; existing:medium/:largecallers are unaffected. Validation is canonical throughMob.UI.normalize_sheet_detents!/1and re-applied at the renderer boundary, so a hand-built or~MOBsigil 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 explicitaccessibility_role: :buttonthat survives independently of whether an event handle is present, anddisabledsemantics. A disabled interactive Box stays a disabled button and does not dispatch; a passive labelled Box does not become a button. Sheetis now in both packaged platform tag manifests (priv/tags/ios.txt,priv/tags/android.txt), so the~MOBsigil andMob.ScreenCaseaccept it.
Fixed
- iOS launch deadlock (App Store rejection).
Mob.Screen.init/1callssafe_area()before the first screen mounts, and its unboundeddispatch_syncto 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 markedERL_NIF_DIRTY_JOB_IO_BOUNDso 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_widthrows left-align rather than centring. @external_resourceon 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 manualmix 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/:largewhen the sheet body is itself scrollable. - A content sheet presents at
:mediumfor 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 shapeMob.UI.sheet/2documents for:on_dismissand 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 withFunctionClauseError(or silently dropped the dismissal and could never re-present the sheet). Pairs with mob_new 0.4.24, which adds thenativeSendDismissextern and JNI thunk — generated Android apps need both halves, andmix 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
:idwasn't laid out (a lazy row below the fold), the old screen's entry survived andMob.Test.tap_id/2tapped its coordinates.
Changed
Mob.Test.element_frames/1docs 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/1no 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": alazy_listrow 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 — andMob.Test.tap_id/2would 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_rootapplies 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
ForEachkeys 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:idbeneath 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 3ModalBottomSheet) 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/:androidstyle overrides via the existing platform-block mechanism. Seedecisions/2026-08-26-native-sheet-primitive.mdfor the presentation- state-via-identity design, the background/corner_radius double-application avoidance on both platforms, and the documented iOS scrim-opacity limitation (native.sheetdoesn't expose dimming-layer opacity — Android applies:scrimexactly, iOS stays system-black).
Fixed
- Drag-indicator completeness validation (all four geometry props
together or none) is now checked against
:ios/:androidoverrides 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: 0on a sheet is no longer indistinguishable from "not set" — square corners are now representable and distinct from the system default. - iOS: a sheet's
:idno longer reports a 0x0 frame viaMob.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/1no 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). Seedecisions/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.Componentinstances to fill the fixed pool (originally 64 slots) got the samebadargas a malformed pid, which crashedMob.ComponentServer.initand, via the unmatched{:error, _}inMob.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 ofbadarg;Mob.ComponentServerlogs and fails just that one component, leaving the screen alive. - Slot 0 (a legitimate pool index) was conflated with the
:no_rendersentinel (also0), soterminate/2never deregistered it — permanent leak. The sentinel is now-1. Mob.ComponentServernever trapped exits, soMob.ComponentRegistry.reconcile/2'sProcess.exit(pid, :shutdown)(the real production stop path) never ranterminate/2at 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. Seedecisions/2026-08-26-component-pool-trap-exit.md.MAX_COMPONENT_HANDLESbumped 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.pushhot-deploying a newer BEAM without a native rebuild) returns a bare int on success and raises on exhaustion —Mob.ComponentServernow 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 foron_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 noMob.Rendererchanges 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)
- A full pool now returns
[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 viaenif_make_string.Mob.ComponentServerdecodes the payload with:json.decode/1, which requires a binary — the component process crashed beforehandle_event/3ever ran. Both bridges now emit UTF-8 binaries;Mob.ComponentServeralso 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_pointeven 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 testwas intermittently flaky:test/mob/component_test.exs'sMob.ComponentRegistrydescribe block usedstart_supervised!/1-style strict matching against a fixed-name GenServer thattest/mob/component_server_test.exs(added for MOB-98) can legitimately start first underasync: 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/1silently swallowed a malformed manifest. A syntax error or raised exception while evaluatingpriv/mob_plugins.exswas 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, seeMOB_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.mdfor the full design.Mob.Theme.font/2builds 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.Themegainedfonts(a name → spec map, referenced from a node'sfont:prop the same way:primary/:on_surfacereference the color map) andfont_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/1andfont_fallback_list/1are the accessors.- Any node that doesn't set its own
font:prop picks up the theme'sfonts[: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:}inpriv/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_fallbackviaTypeface.create, skipping any name that resolves toTypeface.DEFAULT(Android's silent signal that a name wasn't found — see Fixed, mob_new); iOS walks the same list viaUIFont(name:size:), which correctly returnsnilfor an unknown name. Mob.Theme.set/1's native push (notify_native/1) now also ships_font_fallbackto both platforms alongside the existing color palette.
Fixed
- Data race on
g_font_fallbackinios/mob_nif.m. The fallback list was a plainstatic NSArray *written from the BEAM's calling thread innif_set_themeand read from the main thread inmob_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 viadispatch_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'sfontFallback) had always had this covered via@Volatile. (MOB-94)
[0.7.24] - 2026-08-20
Fixed
- Android
Mob.Device.orientation/0could read freed memory.mob_send_orientation_changedstored the raw JNI string pointer handed to it by the trampoline inandroid/jni/beam_jni.c.eex, which releases that buffer as soon as the call returns (GetStringUTFChars/ReleaseStringUTFChars). Any laterMob.Device.orientation/0call 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, mirroringtransportCode/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
*_fieldvariants collapse into Mob's own:text_fieldprimitive 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, andguides/theming.mdnow 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, andguides/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.mdcovering Mishka Chelekom's 60+ ported components (native SwiftUI/Compose, driven by Mob's theme tokens), with cross-references fromguides/styling.mdandguides/theming.md. Also documents the Linear (team MOB) issue-tracking convention inCLAUDE.mdand fixes a malformed.gitignoreline that had silently disabled.DS_Store/.playwright-mcp/ignoring.
[0.7.20] - 2026-07-11
Changed
- iOS
screenshot/3can 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/3uses 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/3and 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 frommob_dev'sios_release_screenshot: trueconfig. The private synthetic-input NIFs stay strictly#if !MOB_RELEASEand 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.MotioniOSaccelnow matches Android's units and sign. The iOS NIF emitted CoreMotion'suserAcceleration + gravityverbatim — 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). iOSaccelwas 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'sa_coord − g_fieldexactly — +g up at rest, m/s², correct for both the static tilt term and the dynamic linear term.gyro(rad/s) andmag(µT) already matched and are unchanged. Theaccelconvention is now a documented contract in theMob.Motionmoduledoc. (#70)
[0.7.18] - 2026-07-07
Added
Mob.Audiooutput probes — "is sound actually coming out right now." The audio analog ofscreenshot.Mob.Audio.output_status/0→%{volume, muted, route, other_audio}(cheap, no permission; catches the common silence causes — muted, zero volume, dead route — via iOSAVAudioSession/ AndroidAudioManager).Mob.Audio.output_level/1→{rms_db, peak_db} | :silent | {:error, reason}, the actual signal energy ofMob.Audio's own player (iOSAVAudioPlayermetering; AndroidVisualizeron the player's session, needs runtimeRECORD_AUDIO);source: :mixreturns{:error, :unsupported_on_platform}. (#54)Mob.Audioinput-level metering — the agent "ears" (MOB-35).Mob.Audio.start_input_metering/1,input_level/0,stop_input_metering/1;input_level/0returns{rms, peak} | :silent | {:error, reason}— the same shape asoutput_level, so mic and output read through one unified metering contract. NIF declared inmob_nif.erl; puredecode_level/1host-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);falsereleases it. No permission on either platform. iOS:UIApplication.isIdleTimerDisabled; Android: the window'sFLAG_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) —dumpsysshows theKEEP_SCREEN_ONwindow 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:unavailablewhere the OS can't answer (never a misleadingfalse) —validated(AndroidNET_CAPABILITY_VALIDATED, a real-internet probe;falseon a captive portal) andconstrained(iOS Low Data Mode). Addsonline?/0and a:networksubscribe category delivering{:mob_device, :connectivity_changed, state}on change. iOSNWPathMonitor; AndroidConnectivityManager.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/1in the "first screen" example (#63), and make the0xAARRGGBBcolor format explicit vs CSS hex (#64).
[0.7.15] - 2026-07-04
Added
- Torch / flashlight support (
Mob.Torch).Mob.Torch.on/1,off/1, andset/2toggle 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:magnetometerin the sensor list and the{:motion, _}message additionally carriesmag(calibrated field, µT) andheading(degrees from magnetic north). The keys are present exactly when you requested:magnetometer, on both platforms, and each isnilwhen there's no reading (device has no magnetometer, or the heading hasn't fused yet) — so a compass app matches onnilrather than hitting a missing key, and accel/gyro-only consumers get the byte-identical 3-key map with no extra sensor cost. iOS uses theXMagneticNorthZVerticalreference frame (CMMotionManager); Android fusesTYPE_MAGNETIC_FIELD+TYPE_ROTATION_VECTOR(SensorManager), registered only on request. Magnetic north only (true north needs location + declination — layerMob.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.ComponentvsMob.Compositein the Components guide. The~MOB: <Tag> is not in the Mob tag whitelistwarning 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 separatesMob.Component(the existing native-view behaviour, whoserender/1returns a native props map) fromMob.Composite(pure-Elixir tag expanders returning a~MOBtree viaexpand/3), and the planned "sub-component event isolation" note no longer reuses theMob.Componentname. (#53, #58)
[0.7.12] - 2026-06-30
Fixed
~MOBnow raises a clear error when@foois used withoutassignsin scope. The@foo→assigns.fooshorthand (0.7.11) only works inside arender(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 withMacro.Env.has_var?(caller, {:assigns, nil})(the same check Phoenix's~Huses) and raises aCompileErrornaming 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@assignssection documents that@fooonly works whereassignsis in scope and steers helpers to positional{var}. (#56, #57)
[0.7.11] - 2026-06-27
Added
- LiveView-style authoring in the
~MOBsigil. Three HEEx idioms now work in templates:@fooshorthand — inside any{...}expression@foorewrites toassigns.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:ifdrops the element from its parent's children.:for={x <- list}— repeats an element per item and splices into the parent. Combined with:if, the:ifbecomes a comprehension filter (LiveView semantics).:if/:forrequire a{expr}value; only those two control attributes are recognised.
Mob.Socket.update/3andassign_new/3, mirroringPhoenix.LiveView.update/3applies a function to an existing assign (KeyErrorif absent);assign_new/3lazily 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
baselinerow alignment on iOS. A:rowwithalign: "baseline"now maps to SwiftUI's.lastTextBaselineinstead of silently falling through to center. (AndroidRowhas no row-level baseline alignment, so it still centers there.)
[0.7.9] - 2026-06-26
Fixed
- Non-glass
:boxfill ignoredcorner_radiuson iOS.mobBoxBackgroundfilled 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 within: 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 scaffoldedMobBridge.ktpredatesopenSettingsno-ops instead of crashing (addMobBridge.openSettings/1to wire it up). (#50)
[0.7.7] - 2026-06-24
Fixed
- Boot crash on all apps (regression in 0.7.6).
device_orientation/0anddevice_lock_orientation/1were added tomob_nif's native NIF tables and-exportin 0.7.6 but not to its-nifs([])attribute.load_nif/2rejects a library that registers a NIF not declared in-nifs, soon_loadfailed,mob_nifwas 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). Neworientation/0query, an{:mob_device, :orientation_changed, orientation}event under the existing:displaysubscription category, andlock_orientation/1/unlock_orientation/0to 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 viarequestGeometryUpdate(iOS 16+); the lock holds once the app shell's root view controller reportsmob_locked_orientation_mask()from-supportedInterfaceOrientations(companion shell change). Android locks viaMobBridge.orientationLock/1→Activity.setRequestedOrientation, with change delivery fromMainActivity.onConfigurationChanged(companionmob_newchanges). Androidorientation/0returns 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 SwiftUIMobCanvasViewrendered draw ops but attached no drag recognizer, so a canvas'son_draghandle (wired through the NIF tonode.onDrag) was never invoked — continuous finger-drag was dead on iOS, while Android'sMobCanvashaddetectDragGestures. Added a canvas-scopedDragGesture(minimumDistance: 0)that callsnode.onDragwith 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_tapsreset 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. aCanvasafter a row ofButtons).register_tapnow builds into the inactive table while readers keep resolving the last committed one;set_rootswaps them atomically undertap_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.Backgroundis no longer in core — it moved to the opt-inmob_backgroundplugin. Background-execution keep-alive (iOS silent AVAudioEngine / AndroiddataSyncforeground service) and itsbackground_keep_alive/background_stopNIFs are removed from:mob_nif. Apps that callMob.Background.keep_alive/0must add{:mob_background, "~> 0.1"}, enable it inmob.exs(config :mob, :plugins, [:mob_background]), and callMobBackground.keep_alive/0instead. 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 unuseddataSyncFGS 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 aMob.Screenin-BEAM, with an optional device backend. Providesmount_screen/3,render_event/render_info, tree queries (find/find_all/text),assert_renderable/2, andnavigated_to/1. On:beamit runs in milliseconds; the same assertions run against real hardware via:device.navigated_to/1returns the destination module on both backends. (#44)
[0.7.1] - 2026-06-16
Added
- Collocated screen templates: a
Mob.Screenwith a sibling<name>.mob.heexand no inlinerender/1getsrender/1compiled from that template (@external_resource, so editing the template recompiles the screen). An inlinerender/1still wins. Opt-in and additive. (#22) Mob.Files.pick/2type filtering::typesnow 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 viaUTType(extensions resolve even for unregistered custom types); Android SAF filters by MIME only, soMob.Files.accept/2+matches?/2enforce the filter on results for consistent cross-platform semantics. Backward-compatible — the default:anypreserves the previous "offer everything" behavior. Seedecisions/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 manifestui_componentsexpand:form, orMob.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 threadingself()). Hot-pushable. Seedecisions/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 intomount/3— the enabler for data-driven plugins (mob_ash registers/ash/postas{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 themob_themespackage. - Boot-time plugin NIF loading (
mob_notify_set_screen_pidseam,host_requirementsprinting,compositesboot 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(thecamera_previewnode stays in core)Mob.Location→mob_locationMob.Notify→mob_notify(delivery plumbing — delegate, push-token forward, launch handoff — stays in core; pairs with the server-sidemob_push)Mob.Photos→mob_photosMob.Biometric→mob_biometricMob.Scanner→mob_scanner(requiresmob_camerafor the:camerapermission)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 inmob.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 + aMob.Pluginsmodule 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.Pluginsruntime 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, andregister_screensrejects anilmodule/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 --nativebundlespriv/fonts/*.ttf|otfand pluginassets.fontsinto the platform bundle — iOS into the.app+Info.plistUIAppFonts(feeding SwiftUIFont.custom), Android intores/font/<normalized>(uncompressed; the renderer loads it by resource id, fixing the previousTypeface.createstub 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 bymix mob.regen_plugin_manifest) that the newMob.Pluginsmodule reads at boot. Tier 3: plugins ship wholeMob.Screenmodules (static:screensor spec-v2:screens_generatorcodegen run under the host-config audit), registered as navigable routes inMob.Nav.Registry; plus:migrations(build-copied into the host migrations dir, namespaced + version-preserving, run by the host'sEcto.Migrator) and:assets. Tier 4::lifecycle(on_start+ supervised children +on_resume/on_backgroundviaMob.Plugins.Supervisor/LifecycleandMob.Device),:settings(Mob.Plugins.get_setting/2/put_setting/3onMob.State, schema-validated, with aneditor_screen), and:notifications(Mob.Plugins.dispatch_notification/1first-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.bootcaptures the host OTP app name at compile time viause Mob.App(a mob release boots withoutApplication.start, soApplication.get_application/1is nil at runtime).
Changed
- Location fully extracted to the standalone
mob_locationplugin (Wave 2). Seeplugin_extraction_plan.mdanddecisions/2026-06-05-mob-location-extraction.md.Mob.Location(get_once/start/stop), the iOSCLLocationManagerNIFs + delegates, the AndroidFusedLocationProviderClientZig NIF +mob_deliver_location, and the hardcoded"location"branch ofnif_request_permissionare removed from core (lib/mob/location.ex,ios/mob_nif.m,android/jni/mob_nif.zig,src/mob_nif.erl).mob_locationis a cross-platform tier-1 plugin: it ships an Objective-C iOS NIF (lang: :objc) and an Android Zig NIF (lang: :zig, viaMobLocationBridge), registers the:locationcapability through the extensible permission registry (iOSmob_register_permission_handler, AndroidMobPermissionProvider), and declares its Android permissions + iOS plist key +play-services-location+CoreLocationframework 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 usedMob.Location.*should add{:mob_location, "~> 0.1"}(orpath:/github:) and callMobLocation.*. The same location surface was removed from themob_newgenerated-app templates. Device-verified on a physical iPhone (SE) and Android (Moto G) both before and after the core strip —MobLocationround-trips real fixes through the plugin alone, and:mob_nif.location_get_once/0now raisesUndefinedFunctionError.
Fixed
- iOS: stop capping the literal super-carrier at 10 MB.
mob_beam.mappended a hardcoded-MIscs 10after the configured flags; since allocator flags are last-wins, it silently overrode the 0.6.24-MIscs 128default (and anymob_beam_flagsoverride), so the literal area was always 10 MB. A large app (e.g. embedded Livebook) plus a notebook'sMix.installfilled it and the VM aborted withliteral_alloc: Cannot allocate .... Removed the hardcoded cap; the-MIscs 128default now takes effect (iOS accepts a 128 MB reservation). Verified on a physical iPhone:emu_argsshows a single-MIscs 128andMix.installreturns:ok.
[0.6.25]
Added
- "Open with" — receive a file another app opens into yours. New
Mob.Files.take_opened_document/0returns%{path, name, mime, size}(or:none) for a file handed to the app (e.g. a notebook emailed and tapped), parallel toMob.Files.pick/2's{:files, :picked, …}. Call it from your root screen'smount/3; a file opened while already running arrives as{:files, :opened, item}(iOS). New NIFtake_opened_documentplus C-exportmob_set_opened_documenton both platforms (iOSapplication:openURL:options:→mob_handle_opened_url; AndroidMainActivityreads the ACTION_VIEW/SEND intent →MobBridge.setOpenedDocument). The app declares the document type (iOSCFBundleDocumentTypes, Android<intent-filter>) and forwards the open. Verified end-to-end: a.livemdopened 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 128default 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'sMix.installfills that 10 MB and the VM aborts withliteral_alloc: Cannot allocate N bytes (of type "literal"). The iOS native launcher's default flags now request a 128 MB literal carrier — a virtualMAP_NORESERVEreservation (commits physical only on use) that iOS accepts where 1 GB fails. Apps no longer need a per-appbeam_flags:override for this. iOS-only; Android keeps its normal large carrier. A runtimemob_beam_flagsoverride still wins. Verified on a physical iPhone: embedded Livebook serves andMix.install([{:short_uuid, "~> 0.1"}])returns:ok.
[0.6.23]
Added
- Element positions without a screenshot.
element_frames/0NIF surfaced asMob.Test.element_frames/1(%{id => {x,y,w,h}}),frame/2, andtap_id/2(drive by id at real coordinates). Any rendered node given an:idreports 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:idas the element's accessibility identifier (iOSaccessibilityIdentifier, Android ComposetestTag), so the same tags are visible to XCUITest/Espresso. Opt-in per element: untagged nodes cost nothing (the tracking modifier only attaches when an:idis present). iOS records the full element frame via aGeometryReaderbackground; Android viaModifier.onGloballyPositioned. Verified on iOS sim, Android device, and a physical iPhone. The Android Kotlin side lives in themob_newMobBridge.kt.eextemplate. - In-process screenshot + scroll control over dist (no adb/xcrun). Three test-harness NIFs (
screenshot/3,scroll_info/1,scroll_to/3) surfaced asMob.Test.screenshot/2,scroll_info/2,scroll_to/4, andscreenshot_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 (iOSUIGraphicsImageRenderer+drawViewHierarchy; AndroidPixelCopyagainst the activity window). Scroll views are addressed by their:idprop;scroll_inforeportskind: :pixel(iOSUIScrollView, AndroidverticalScroll) or:index(AndroidLazyColumn, 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 themob_newMobBridge.kt.eextemplate; existing apps pick it up on regeneration. Debug-only (iOS#if !MOB_RELEASE). Seedecisions/2026-05-29-bridge-nif-screenshot-scroll.md.
Changed
Mob.Btfully extracted to the standalonemob_bluetoothplugin (Wave 1 complete). Seeplugin_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 fromandroid/jni/mob_nif.zigand the iOS unsupported-stubs fromios/mob_nif.m.mob_bluetoothis now a tier-1 plugin: it ships its own Zig NIF, JNI thunks, andMobBluetoothBridgeKotlin, 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 usedMob.Bt.*should add{:mob_bluetooth, "~> 0.1"}(orpath:/github:) and rename references toMobBluetooth.*. 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_keycacert 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 withno_cacerts_found(orFunctionClauseErrorin some OTP versions). Apps bundle a PEM (conventional source: copycastore'scacerts.pemintopriv/at build time) and callMob.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"}])thenReq.get!("https://geocoding-api.open-meteo.com/v1/search?name=Vancouver")returns200.mob_beam.zigexportsMOB_NATIVE_LIB_DIRbefore 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.) aslib*.soneed this to setMIX_REBAR3and locate the bundled escripts.- Optional ERTS-extras symlinks (
escript/erlexec/erl/beam.smp) inmob_beam.zig. Silent-skips when the lib isn't in nativeLibDir, so non-opting-in apps see no behaviour change. Apps that droplib<name>.sointoandroid/app/src/main/jniLibs/<abi>/get a workingBINDIR/<name>— enough for runtimeMix.installof rebar3-built deps (telemetry, jose, jiffy, …) to bootstrap a fresh VM.erlanderlexecboth target the sameliberlexec.sobecause they are the same binary (erlexec doesn't switch onargv[0]).
Changed
extra_applications: [:logger, :public_key]— Elixir 1.19+ strips unused OTP applications from the code path;Mob.Certscalls:public_key.cacerts_load/1at runtime, so its.beammust be in the path even though mob doesn't start:public_keyitself.
Fixed
mix.exs— collapsed duplicatebefore_closing_body_tag/1clauses 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 duplicatedocs/0keyword 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/*.bootmaterialization) for apps that opt into runtime rebar3.
[0.6.21]
Added
Mob.DNS.resolve/1now works on Android.nif_resolve_ipv4(android/jni/mob_nif.zig) calls Bionic'sgetaddrinfoin-process and seeds:inet_db's:filetable, mirroring the iOS NIF added in #32. Physical Android devices return:nxdomainfrom BEAM's default DNS path (forkinginet_gethostas 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/2then succeeds via the seeded entry, andMix.install([{:dep, "~> ..."}])from a notebook setup cell resolves, fetches, and compiles on-device. Bionicaddrinfo/sockaddr_in/getaddrinfo/freeaddrinfo/EAI_*bindings added toandroid/jni/mob_zig.zig. Suspected root cause islibnetd_client.so's netd routing not surviving execve; the NIF sidesteps it by running in the app's own process.
Changed
Mob.DNSmoduledoc — 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/:timeouton 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:nxdomainsymptom on physical Android, the foreground-app caveat, and the fix.
[0.6.18]
Changed
RUSTLER_NIF_LIB_PATH→RUSTLER_BEAM_LIBRARY_PATHinmob_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 viaProcess.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 existingMediaPlayerpath (port to AAudio is pending).- iOS:
nif_audio_play_at(Path, OptsJson, AtWallMs)backed by a dedicatedAVAudioEngine+AVAudioPlayerNode. The wall-time target is converted to anAVAudioTimehostTimeviamach_absolute_time+mach_timebase_info, then handed to-[AVAudioPlayerNode scheduleBuffer:atTime:options:completionHandler:]. Past targets schedule ASAP. Multipleplay_atcalls accumulate on the player's timeline — useaudio_stop_playbackto flush. audio_set_volumeandaudio_stop_playbacknow 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.zigexportsRUSTLER_NIF_LIB_PATHbefore BEAM start. Callsdladdr(&mob_start_beam)to discover the absolute path of the host.so(e.g.lib<app>.so) andsetenv()s it asRUSTLER_NIF_LIB_PATH. Pairs with the matching upstream rustler change (rusterlium/rustler#726): rustler'sDlsymNifFiller::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.sonow resolveenif_*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.zigexposesdladdr+DlInfoto other Zig consumers underjni.dladdr/jni.DlInfo. Hand-declared to match the libc/Bionic surface; same hand-declared FFI policy as the rest ofmob_zig.zig(we don't use@cImporthere).
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)→dlsymallenif_*exports → Rust NIFgreet/0executes and returns"Hello from Rust!"to BEAM.
[0.6.15]
Added
text_fieldnow accepts asecure: trueprop. iOS renders the field as a SwiftUISecureField(masked input) instead of the plainTextField. The prop flows through the existing renderer passthrough; cleartext still reaches the BEAM viaon_changeso apps can hash/store the value as normal. Android consumes the same prop viaPasswordVisualTransformationoncemob_new'sMobBridge.kt.eextemplate 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 ofMobTextFieldand warrants its own change.
Fixed
- iOS:
Mob.App.start/0now switches:inet_dbto file-only lookup and seedslocalhostbefore any user code runs — BEAM's default:nativelookup tries toexecvetheinet_gethostport program, which the iOS sandbox refuses, crashing the firstNode.connect/:erpc.call/gen_tcp.connect/3with:badarg. Apps no longer need to set the lookup chain themselves;Mob.DNS.configure_pure_beam/1still composes on top for outbound DNS. Seeguides/dns_on_ios.md. - iOS:
Columnnow honoursfill_height: true. The.columncase inMobRootViewonly setmaxWidth, so aColumnwithfill_height: truewould collapse to its children's natural height — breaking the canonical<Column fill_width fill_height>header/flex/footer pattern. Now setsmaxHeight: .infinitywhen the prop is set and switches alignment to.topLeadingso children anchor at the top when the column flexes. Default (nofill_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 speculativeplugin_spec_version: 3. Companionagent_briefs/rustler_env_var_test.mdcovers filmor's env-var-based fix inrusterlium/rustler#726.
[0.6.14]
Added
:mob_nif.set_theme/1— push resolved theme palette to native. Lets a ComposeMaterialThemewrapper follow runtimeMob.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 toColor(...)directly.
Notes
- iOS implements the NIF as a no-op for symmetry — SwiftUI in
MobRootView.swiftrenders 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 viacacheOptional, so older templates that predate this load fine; the NIF just returns:okwithout dispatching when the method isn't on the bridge. - The mob_new generator templates that wire
MaterialTheme↔setThemein newly-generated apps will follow in a separate release; existing apps adopt manually (aMutableStatein MobBridge.kt +MaterialTheme(colorScheme = …)wrap in MainActivity.kt).
[0.6.13]
Changed
- Liquid Glass uses
Glass.clearinstead ofGlass.regular. On dark surfaces with little behind a card to refract,.regularreads as a frosted plate rather than glass..clearis 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.ultraThinMaterialfallback for older iOS is unchanged).
[0.6.12]
Added
Mob.Theme—glassflag for translucent surfaces. Newglass: falsefield on the theme struct. When set,Mob.Renderertags everyBoxnode that has abackground:withglass: true, and the iOS side swaps the solid fill for.glassEffect(.regular, in: shape)on iOS 26+ (real Liquid Glass) or.ultraThinMaterialon iOS 17–25 (closest fallback that ships in older SDKs). Other nodes pass through untouched. Opt in via a preset or by passingglass: truetoMob.Theme.build/1.Mob.Theme.ObsidianGlass— Obsidian palette +glass: truefor the common "make the whole app glassy" case. Switch at runtime withMob.Theme.set(Mob.Theme.ObsidianGlass); revert withMob.Theme.set(Mob.Theme.Obsidian).Mob.Theme.flags_map/1— companion tocolor_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
~MOBsigil no longer double-encodes non-ASCII bytes in template source. The NimbleParsec parser usedascii_string/2for string attribute values (text="...") and brace content (text={...}); itsinteger-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 toutf8_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 viatext={var}.
[0.6.10]
Added
- iOS BEAM startup honours
MOB_NODE_SUFFIXenv var. The simulator branch already auto-derived a unique node-name suffix fromSIMULATOR_UDIDso concurrent sims didn't collide in Mac's EPMD, but there was no manual override path — the Android-sideMOB_NODE_SUFFIXconvention was iOS-blind. Now both branches (simulator + physical device) readMOB_NODE_SUFFIXwith priority: explicit env → SIMULATORUDID-derived (sim only) → none. Pairs withmob_dev 0.5.10'smix mob.deploy --node-suffix Xflag (forwarded to simctl via the `SIMCTL_CHILD*` mechanism). - Resolves the
Protocol 'inet_tcp': register/listen error: no_reg_reply_from_epmdsymptom 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.hreformatted to satisfyxcrun clang-format --dry-run -Werror(the camera-frame delivery declaration was split across three lines in a style clang-format wanted on two).decimalbumped 2.4.0 → 3.1.0 (transitive viaecto_sqlite3/jason) to clear advisory GHSA-rhv4-8758-jx7v — unbounded exponent inDecimal.new/1enables an unauthenticated DoS, affects< 3.0.0.jasonbumped 1.4.4 → 1.4.5 since older Jason cappeddecimalto~> 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/2now works on Android. The Camera2 + CameraXImageAnalysisuse case is wired through to BEAM as{:camera, :frame, %{bytes, width, height, format, timestamp_ms, dropped}}messages. Previously this NIF returned:unsupportedon Android — iOS-only. The Android implementation supports the sameformat: :rgb_f32the iOS side does (:bgra_u8planned for a follow-up).Mob.Canvasmoduledoc documents the viewport-scaling contract: thewidth/heightprops are logical viewport units, NOT pixels. The renderer scales draw-op coordinates against the actual on-screen pixel size. New tests intest/mob/canvas_test.exspin the contract so future readers don't regress to interpreting them as raw pixels.
Notes
- Combined with
mob_dev 0.5.9'smix mob.enable tfliteand thenx_tflite_mob 0.0.3Hex 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 oflib/mob/andsrc/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 amix docspreview step and clarifies that hexdocs publishing is automatic viamix 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 ofrelease.yml. Linked frommob_devandmob_newCLAUDE.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_scanwhere present) only whenmix.exschanged. Activate per-clone withgit 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
mainbranch — corrected tomasterso each</>glyph next to a heading now opens the actual source file in the GitHub repo. mob_nif.zigcalled the variadicenif_make_list/2(not exposed inmob_erts.zig) from the BT paired-list finisher; the Android arm64 build failed at link. Switched to the non-variadicenif_make_list_from_array(env, &empty, 0).
Added
.github/workflows/test.yml— runsmix test,mix format --check-formatted,mix credo --strict,mix erlfmt --check src/,xcrun clang-format,swiftlint, andmix deps.auditon 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 withv_uv); user supplies an MSL fragment shader plus a list of uniforms packed at natural alignment into fragment-buffer slot 0. SwiftUIMobGpuViewwraps anMTKViewwith 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 bothpriv/tags/ios.txtandpriv/tags/android.txt.
[0.6.3]
Fixed
- iOS camera sensor delivered frames in landscape-right by default —
Mob.Camera.start_frame_stream/2was 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/2andstop_frame_stream/1— push-driven per-frame delivery as{:camera, :frame, %{bytes, width, height, format, timestamp_ms, dropped}}. Defaults to 640×640rgb_f32for direct Nx hand-off; caller-overridable width/height/format/facing and a softwarethrottle_msgate.
Changed
- iOS camera now uses a single shared
AVCaptureSessionfor 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 and the per-tag commit messages for history.