All notable changes to mob are documented here.
Format: Keep a Changelog. Versioning: SemVer.
Full module documentation: hexdocs.pm/mob.
[Unreleased]
[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 aMob.Defect.CapsuleonMob.Defect.Bus(kind::beam_crash, owner::mob).Mob.PostMortem.sweep/0andMob.PostMortem.sweep/1do a one-shot scan of default and caller-supplied paths. Nothing runs automatically — an app opts in from itson_start; a developer or CI can call it from IEx. Same discipline the rest ofMob.Defectfollows: mob owns the format and the bus, never becomes the collector.Mob.PostMortem.BeamCrashDumpscans forerl_crash.dumpfiles, 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 withcrashdump_viewer.Mob.PostMortem.Registryrecords 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 onio:put_chars/2with 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 literalnormalexit is:info; anything else is:critical.iOS MetricKit ingest for
Mob.PostMortem.IOS(MOB-179). Replaces the phase 1 scaffold.Mob.PostMortem.sweep/0on iOS now attaches anMXMetricManagerSubscriberon 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 intoMob.Defect.Capsules 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;MXCPUExceptionDiagnosticandMXDiskWriteExceptionDiagnostic→:perf_regression/:warning. (These are the individual diagnostics; theMXDiagnosticPayloadcontainer 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.JSONRepresentationrides 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[]—ApplicationExitInfois a separate ticket (MOB-180).Android ApplicationExitInfo ingest for
Mob.PostMortem.Android(MOB-180). Replaces the phase 1 scaffold.Mob.PostMortem.sweep/0on Android now pulls the OS-held history of process exits viaActivityManager.getHistoricalProcessExitReasons(API 30+), filters against a persistent marker at<filesDir>/mob_post_mortem_appexit_marker.txtso each exit emits exactly once across boots, and turns each new entry into aMob.Defect.CapsuleonMob.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.zigrather than aMobBridge.kt.eextemplate addition. Every existing mob app gets the feature the moment they bump mob, with no template refresh dance. Seedecisions/2026-09-11-appexit-native-jni-not-bridge.md. iOS registers a stubnif_post_mortem_android_drainreturning[]so the sharedmob_nif.erlNIF 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.Capsuleis themob.defect/1schema fromdecisions/2026-09-04-defect-reports-are-a-shipped-feature.mdin Elixir:Capsule.new/1builds the struct, populatesbuildanddeviceautomatically, tagsredaction: :appliedon 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/3is a sha256 overkind + owner + fingerprint_keyonly — time, id, build and device are excluded so a defect groups across releases and devices.to_json/1produces the wire form; the whole capsule round-trips through Jason.Mob.Defect.Busis an in-process pub/sub with two ETS tables (aclassesset keyed by fingerprint that carries the first capsule and an occurrences counter, and a bounded ring of the 64 most-recent capsules).emit/1is on the write path — no GenServer round-trip, mirroringMob.Agent.Receipts.record/1. Subscribers are stored in:persistent_termand monitored by the owner GenServer so a subscriber exit prunes itself; a dead pid in the fanout list is a no-op becausesend/2does not raise on a dead pid.Mob.Defect.Sinks.Devis an opt-in GenServer that subscribes oninit/1and Logger-writes each capsule at a severity-driven level (:fatal/:critical→error,:warning→warning, elseinfo). Not started by default — per the decision record, mob owns the format and the bus but never the destination.Mob.Defect.emit_invariant_violation/1andemit_divergence/2encapsulate 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.mdfor the fingerprint-vs-evidence design.
[0.8.0] - 2026-09-11
Added
Mob.Differential.compare/3(MOB-157). A pure comparator over twoMob.Test.view_tree/1snapshots — pass an iOS and an Android tree, get back:okor{: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'sMobBridge.uiViewTree()reports frame only for nodes withprops["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_colorare not compared yet — both arenullon Android today, and a partial answer would misattribute divergence.Returns
{:error, :not_ready}when either side isnil,{: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. Seedecisions/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
:criticalon 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/2takes 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_navis registered for:periodic, and nothing drives:periodicyet; 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 inBuiltins.unimplemented/0with what each needs, and a test asserts none is ever silently registered.Measured through
run/2at 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/2re-measures on the device that matters. Seedecisions/2026-09-10-an-invariant-must-survive-to-the-next-sample.md.Causal receipts —
Mob.Agent.Receipt(MOB-155). Every dispatched event now gets anaction_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 arender/1that never reads:count.The stages are observed, not reported: only
handledis 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
:unknownrather 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, becauseKeyErrorand friends embed the term that failed and would otherwise carry the whole assigns map into telemetry.Mob.Agent.Receipts.fetch/1retrieves one by id;recent/1lists the newest. Bounded at 256, withcount/0anddropped/0so a missing receipt can be told apart from an id that never existed.Emits
[:mob, :action, :stop]only when the host app already has:telemetryloaded —mobkeeps its single runtime dependency.native_commitis:unknown: this says what the BEAM did, not that the pixels changed. Seedecisions/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_areareturned zeros when it could find no window, which is indistinguishable from a device that genuinely has no insets, andensure_safe_area/3stopped 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 readassigns.safe_areadirectly, so a missing key would be aKeyErrorinrender/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 fromscene: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_windowraises inMob.Screen.Server.init/1and the root screen never starts — so do not pointmob.exs'smob_dirat a newer checkout than themix.exsdependency.
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_getandwebview_can_go_backall did, and thirteen moredispatch_syncon 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_newrelease).This makes true a rule that
decisions/2026-09-05-input-nifs-are-dirty-io.mdstated 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.ComponentRegistryis a globally-named singleton owning a named ETS table, and twoasync: truemodules each started it withstart_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 intest_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 useMob.Test.ProcessHelpers, which gainsstop_pid/2,await_exit/2andeventually/2.All 35
Process.sleepcalls were classified rather than swept. Twelve areProcess.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 (aGenServer.callfrom 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. Seedecisions/2026-09-06-tests-wait-for-events-not-durations.md.Also fixes a temp-directory collision between concurrent
mix testruns:System.unique_integer/1is unique per VM, so two suites running at once (a CI matrix on one box) generated the same fixture directory and eachon_exitdeleted the other's files.ProcessHelpers.tmp_path/1includes the OS pid.
Changed
Input NIFs now run on a dirty IO scheduler.
tap,tap_xy,long_press_xy,swipe_xy,type_text,delete_backwardandclear_texton both platforms, pluskey_press,ax_actionandax_action_at_xyon iOS, are registeredERL_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 800mslong_press_xywas stopping every process on the device — timers, renders,:rpc, PubSub — for the duration. iOS'snif_long_press_xyhad been sleeping the caller's full duration on a normal scheduler since it was written, andnif_ax_action_at_xysleeps 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. Seedecisions/2026-09-05-input-nifs-are-dirty-io.md.
Documented
- Android synthetic input in
Mob.Test. The platform matrix now coverslong_press_xy/4,type_text/2,delete_backward/1andclear_text/1, and markstap_xy/3andswipe/5as working on Android — for apps generated bymob_new0.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/2is ASCII-only and rejects a whole string containing one unmappable character, andclear_text/1is deliberately absent on Android rather than broken. scroll_info/2returns device pixels, whileelement_frames/1returns dp andtap_xy/3andswipe/5take dp. Feeding one into the other overshoots by the display density.- The
✱footnote no longer attributestype_text/2,delete_backward/1andclear_text/1to the IOHID injection path they do not use.
Added
max_lineson:text. A positive integer caps the rendered line count and tail-ellipsises the overflow:.lineLimit(n)+.truncationMode(.tail)on iOS,maxLines+TextOverflow.Ellipsison 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.nilis 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. Seedecisions/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 matchingmob_newrelease (or a regenerated bridge); an older bridge ignores the key and wraps as before.Measured
Wraplayout 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.spacingandrun_spacingaccept theme spacing tokens, intrinsically oversized children are proposed the container width, andfill_width: truechildren occupy a full run. Explicitfill_width: falsenow makes aBoxhug its content while an omitted value preserves the historical full-width Box default. The Android renderer ships in the matchingmob_newgenerated template.Native frame timing on Android.
Mob.RenderStats.native_enable/1,native_frames/1andnative_summary/1returned{: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
push841ms and apop916ms — navigation is 3.2x a re-render of the same tree. Seedecisions/2026-09-05-measure-the-native-half-on-android.md, which also records why the obvious closing brackets (MessageQueue.IdleHandler, a plainpost) measure the wrong thing without failing.Android's
apply_usincludes 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 bymob_new0.4.32 or newer — the buffer lives in the app's ownMobBridge.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@specpromising 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 generatedMobBridge.ktlacks the matching method, and that file is generated once and never re-rendered; on iOS the harness is compiled out of release builds. Returns:unknownper probe for an app predatingmob_nif:capabilities/0rather than guessing, andfalseeverywhere withdist_rpc: falsewhen nothing answered. Backed by a newmob_nif:capabilities/0NIF 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
onAppearnever fires again — so a returning screen kept a stamp two navigations old and was rejected for ever.Mob.Test.element_framesandtap_idread 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.Socketalready raisesArgumentErrorontransition: :noneciting 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 —
falsefor a toggle,""for an uncontrolled field — the watcher never fires. For a field withsecure: truethat 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 —
.sheetpresents on the window, so the slot'sallowsHitTesting(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; whileswitch_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 | :nonevocabulary. 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, andMainActivity.ktmatches those four values with an exactwhen, so everyreset_towould 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
resetreleases 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). 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.