# Moving the app to Rust Working document for the port Iris asked for on 2026-09-04: the phone app in pure Rust, one UI framework shared with a desktop app, at full feature parity and giving up nothing native -- performance especially. Her constraints: no Dioxus and nothing that draws through a WebView; **no UI DSL** (which ruled out Makepad and Slint); the result stays lightweight; platform-specific pieces are fine to maintain; reimplementing a framework piece from scratch where it does not fit is fine; effort and elapsed time do not matter, long-term robustness does. **The framework question is closed.** Iris chose her own library, [iris](https://github.com/cat16/iris), over Masonry on 2026-09-05. The bake-off that got there, and the twelve experiments that proved it on a device, are summarised in "What the experiments settled" below rather than kept at length. What is left in this file is the plan for the rest of the app and the findings that outlive the tasks that produced them. Decisions get a date and a reason here, the way `PLAN.md` does. ## Keep this file current as you work **This file is the handoff, and it is meant to let a session be cleared.** Write each result into it *as you get it*, not at the end: the box ticked or the reason it could not be, the measurement with its number, the decision with its date and what it rejected, and anything that cost time to find out. Then a session that has filled its context can be cleared and the next one can pick up from this file alone, which is much cheaper than carrying a long conversation or re-deriving what was already measured. Two things that follow. Write for somebody who was not here -- name the command, the file and the number rather than "the fix" or "the earlier run". And write the failures and the dead ends too: "Venus is blocked by the emulator, not by Mesa" and "the present mode was not the cause" are worth as much as the successes, because they are what stops the next session spending an afternoon on them again. **And delete a plan once it has been carried out** (Iris, 2026-09-08: *"remove everything that's already done and decided... many with checkboxes already ticked off that just fill up context"*). A ticked box has done its job; a finished experiment is worth one line saying what it settled, not the log of settling it. Currency means this file says where things *are*, not how they got here. What survives a prune is what cannot be cheaply re-derived: measurements, dead ends, invariants and their reasons. ## Where things stand (2026-09-08) - **The framework is decided and built on.** iris draws the transcript screen on the desktop, on this checkout's emulator and on Iris's phone. - **P0 (the phone benchmark gate) passed** -- both apps ran on her own phone and the reports are under `docs/bench/`. - **P1 (session screen parity) is the current work**, and is where the next session should start. Its box below has the state. - **The repository was reorganised on 2026-09-08**: the port is one crate, `app-rust/`, and `iris/` is the UI framework alone. See "One app crate" at the end -- it is the layout everything else here assumes. - **Open across the rest of the docs**: `docs/IRIS_TODO.md` is iris's own list (streaming re-layout is the live one), `docs/TODO.md` is the Compose app's. ## Desktop and phone share the code (Iris, 2026-09-07) Iris plans to develop a desktop app as well, and asked that most code be sharable between desktop and phone. The tree already has that shape -- `iris` and `app-rust`'s `client` and `ui` modules are platform-free, and `src/android`/`src/desktop` are the entry points -- so the rule is about keeping it: **a platform module holds only what the platform forces.** Today that is JNI, the IME and insets bridge, the surface lifecycle and the bench JNI on Android; winit, argv and the config file on the desktop. **What differs is the screen layout**, since a phone screen with a finger and a desktop screen with a mouse want different arrangements -- a session list beside the transcript rather than a screen behind it, hover states, keyboard shortcuts. **What does not differ is everything a layout is built from**: the widgets (a tap button, a text field, a list, a card, a tool-call row), gestures, folding, paging, selection, and the styling -- colours, spacing, type, the surface ladder -- which is the exact same code on both, never a desktop palette beside a phone one. Those are written once in a shared module, with a platform trait underneath when a behaviour genuinely differs (`FocusHost`, `OpenUrl`, and the insets/`ime_visible` feed are the existing examples). Two checks before finishing a change under `iris/`: does `ai-app-desktop` still build and run with it, and is any UI logic newly in `src/android` that a desktop would also need? The bench client (`app-rust/src/android/bench_client.rs`, ~1000 lines) is the first thing to look at moving, since a desktop bench on the same fixture is layer 2 of the test rig below. ## Three test layers, cheapest first (decided 2026-09-07) Iris's suggestion, adopted and layered: test at the cheapest layer that can answer the question, and go up only when it cannot. The emulator costs minutes a cycle; the desktop window seconds; the headless harness runs inside `cargo test`. 1. **Headless, in-process, no compositor and no GPU -- the default.** `iris::harness` (`iris/src/harness.rs`), plus the fixture crate it opens. `Harness::new(size, density)` builds an `Rsc`, a `UiRenderState` and a state whose `FocusHost`/`OpenUrl` *record* what the platform was asked for; `frame(t_ms)`/`frames_until(..)` run frames on a clock the test owns, and `replay(&TouchScript)` feeds a recorded gesture one sample at a time exactly as `IrisViewPeer::on_touch_event` replays Android's historical samples. The recordings are plain `t_ms action x y` files under `app-rust/touch/`, and `flick-120hz.touch` is the phone's own shape: DOWN, four samples 4ms apart, UP, 20ms in total. cd app-rust && cargo test runs in about a second and asserts (a) the flick releases with a real velocity (`List::fling_velocity`, which only `Released(Some(v))` fills), (b) the list travels and settles inside the AOSP spline's own `FlingCalculator::duration`, (c) a tap moves nothing and opens no link, (d) a long-press-then-drag leaves selected text and does not pan, and (e) the composer clears a simulated 1000px IME inset (`Composer::set_bottom_inset`). Each was confirmed to fail without its subject rather than assumed: dropping `animate(id)` from `Selection::drag` -- the phone's own "fling does nothing" defect -- and starting the fling curve at the wall clock each fail only the flick test; flinging on `Tapped` fails only the tap test; a 5s `LONG_PRESS` fails only the selection test; a `set_bottom_inset` that ignores its argument fails only the composer test. **What still cannot be answered below layer 3**: nothing renders here, so anything about pixels -- glyph rasterisation, the atlas, stale or duplicated primitives, colour, the surface lifecycle, the renderer rebuild -- is invisible to layer 1 and only *looked at* in layer 2. Frame *times* are not measurable at either: layer 1 does no GPU work at all and layer 2 runs a debug build on this VM's virtio GPU, so a number from either is not the phone's. Anything JNI (the IME, real insets, the clipboard, battery) is layer 3 by construction: layer 1 records that the platform was asked and layer 2 has no Android platform to ask. **The one exception, added 2026-09-08**: `iris/tests/mask_sdf.rs` needs a GPU but no compositor and no window -- it asks wgpu for an adapter, runs two functions lifted out of `shader.wgsl` itself in a compute pass, and compares the answers with the CPU transliteration in `iris_core::render::sdf`. It sits inside `cargo test` because what it checks is arithmetic rather than pixels: the fragment stage and the hit test have to agree about where a rounded edge is, and neither layer 1 (which cannot run the shader) nor layer 2 (where a half-pixel disagreement is invisible) can say whether they do. Reach for this shape only when the question is "do these two implementations of one function agree" -- anything about what is *drawn* is still layer 2. 2. **A phone-shaped desktop window under headless sway -- for looking.** cd iris && ./run-headless.sh phone --phone --dir ../app-rust --shot /tmp/p.png About 15 seconds warm. `--phone` sets the private sway output to 1080x2424@120Hz and exports `IRIS_SCALE=2.55`, which reaches iris the way `DisplayMetrics.density` does on Android (`iris::default::content_scale`) -- the desktop backend now lays out in physical pixels with a density instead of dividing into a separate logical space, so both platforms run one path. `app-rust`'s `phone` example opens the same screen from the same bytes as layer 1 and the Android bench. A gesture on screen uses the *same recordings*: ./run-headless.sh phone --phone --dir ../app-rust \ --replay ../app-rust/touch/flick-120hz.touch --shot /tmp/p.png writes `/tmp/p-before.png` and `/tmp/p.png` either side of the flick; looked at 2026-09-07, the list moved back about seven turns of the fixture and settled. **`swaymsg seat - cursor` cannot drive it, and that cost an hour.** This compositor runs the headless backend with no input devices (`WLR_LIBINPUT_NO_DEVICES=1`, `LIBSEAT_BACKEND=noop`): the cursor commands all report `success` and nothing whatever reaches the client, with `swaymsg -t get_seats` showing `capabilities: 0` as the only sign. wlroots 0.19 dropped `WLR_HEADLESS_INPUTS`, and ydotool's uinput device would be ignored by a compositor that is not reading libinput. `iris/rig-input`'s `replay-touch` uses the **virtual-pointer protocol** instead, which is a client protocol and needs neither devices nor root, and it parses `iris::harness`'s own `TouchScript`. Two traps inside it, both found by printing winit's events: a button sent in the same frame as the motion that first puts the pointer over the window is dropped (the client sees the enter, the moves and the *release*, never the press), so the pointer is positioned and left to settle 200ms first; and a leftover window from an earlier manual run **tiles beside the new one**, halving the width and producing a screenshot that looks exactly like a duplicated- primitive rendering bug -- `swaymsg -t get_tree` and `pgrep -af examples/phone` are the check. 3. **The Android emulator -- platform plumbing and the final pass.** JNI, IME, insets, surface lifecycle, the renderer rebuild, and one verification run before a build goes to the phone. Not for iterating on layout. ## What has to be reproduced The app is ~19,000 lines of Kotlin. It splits three ways, and the split is what decides how much of a port is mechanical. **Pure logic with no Compose or Android in it, ~4,500 lines.** `Api.kt` (1,142), `Events.kt`, `EventStream.kt`, `Sse.kt`, `TranscriptCache.kt` (589, touches `java.io.File` only), `TranscriptSource.kt`, `MarkdownSyntax.kt`, `Languages.kt`, `Highlighter.kt`, `Ansi.kt`, `ResetCountdown.kt`, `Durations.kt`, `Sizes.kt`, `ModelName.kt`, `LoadState.kt`, `ImportableStream.kt`. `TranscriptUnits.kt` and `TranscriptItems.kt` (the event fold into rows, ~940 lines) are logic with a handful of Compose annotations. This is also exactly the code that has JVM unit tests today. All of it ports directly, and most of it already has a Rust twin in `server/`: `Events.kt` is a hand-kept mirror of `session/driver.rs`'s enum, the highlighter and the syntax scanner exist on the server for the explorer, and the cache compares the server's own JSON lines. **Sharing these types between server and app is the single largest "keep things in sync" win available, and it does not depend on which UI framework wins.** **Compose UI, ~13,000 lines.** Screens, dialogs, the transcript list, the markdown renderer's customisations, tool cards, the file explorer viewer and editor. This is the part a UI framework choice is about. **Android platform code, ~1,500 lines**, spread over 20 files. Every one of these is a Java-side object that no Rust framework can replace, because Android only offers them as Java classes: - `NotificationService` — a **foreground service** holding the `/notifications` SSE stream while the app is closed, with its ongoing notification, `specialUse` type and the `POST_NOTIFICATIONS` request. - `MainActivity` — edge-to-edge, the `ACCESS_LOCAL_NETWORK` runtime permission (Android 17), `singleTop` intent routing for `aiapp://enroll`, notification taps, and the **share sheet** (`ACTION_SEND`, any MIME type). - `ServerConfig` — the bearer token sealed under an **Android Keystore** AES-GCM key, shared with Dev Updater through `wg-app-link`'s `:link`. - `EnrollmentScanActivity` — the in-app **QR scanner** (zxing, camera). - `Attachments` — `ContentResolver` reads of shared URIs, `BitmapFactory` decode and downscale, **EXIF** orientation. - `SessionImage` — bitmap decode for produced images. - `ScrollAnchor`, `Drafts` — `SharedPreferences`; `CrashLog` — `filesDir`. - `TranscriptCache` — `cacheDir`. - `DebugStats`/`FrameStats` — `Choreographer` frame timing and the render report; `runtime-tracing` names composables in a system trace. So **"pure Rust" on Android means Rust owns every line of logic and drawing, behind a thin shell of Java stubs**, and a packaging step that produces a signed APK. How thin, and whether Gradle is inevitable, are answered below. ### How much Java is unavoidable, and why Rust can *call* any Android API through JNI (`jni` crate, with `ndk-context` handing over the `JavaVM` and the Activity): posting a notification, `startForegroundService`, the Keystore, `ContentResolver` reads, permission requests, `WindowInsets`, the clipboard. None of that needs a line of Kotlin. What JNI cannot do is *define* a class that the system instantiates **by name from the manifest** — an `Activity`, a `Service`, an `Application`, a `BroadcastReceiver`. Those must exist as dex bytecode inside the APK before any Rust runs, because the framework constructs them and only then calls into native code. `NativeActivity` is the platform's own stub for the Activity case; there is no `NativeService`, and android-view ships its own `View` subclass for the same reason. So the floor is roughly **two Java classes of ten lines each**: an `Activity` and a `Service` whose lifecycle methods are declared `native` and registered from `JNI_OnLoad`, plus whatever android-view already provides. Everything they would have done in Kotlin — insets, intent routing, the SSE follow loop, the notification builder — is Rust reached through those stubs. Writing the stubs in Java rather than Kotlin drops `kotlinc` from the toolchain; `javac` comes with the JDK Gradle already needs. Generating the dex from Rust is not worth it: there is no mature Rust dex writer, and the stubs never change. ### Can the APK be built without Gradle? Yes. An APK is a zip containing a binary-XML `AndroidManifest.xml`, `resources.arsc`, `classes.dex`, `lib//*.so` and assets, aligned and signed with the v2 scheme. The tools are `aapt2` (manifest and resources), `d8` (Java bytecode to dex), `zipalign` and `apksigner`, all in the SDK's `build-tools`, none of them Gradle. Three ways to drive them: - **A `cargo xtask`** (or `build.rs`-adjacent script) that runs `cargo ndk` for each ABI, `javac` + `d8` for the stubs, `aapt2 link`, `zipalign`, `apksigner`. About 150 lines, every step visible, no AGP, no Gradle daemon holding 2.8 GB between builds. The pinned-CA constant becomes a `build.rs` reading the same `certs/ca.pem` path. - **[cargo-apk2](https://github.com/mzdk100/cargo-apk2)**: the maintained successor to cargo-apk, and unlike it compiles `java_sources` / `kotlin_sources` into the dex and declares multiple activities **and services** with intent filters from `[package.metadata.android]`, with per-profile keystores and optional `aapt2`. Exactly the shape needed; the question is whether a third-party tool with one maintainer beats 150 lines we own. - **cargo-apk / xbuild**: unmaintained and `NativeActivity`-only. No. What Gradle would take with it: Android Lint (which found two real bugs here, but in Kotlin that would no longer exist — with forty lines of Java stubs there is little left for it to find), manifest merging, R8, and the generated-source plumbing. What it gives back: one toolchain, `cargo` end to end, and Dev Updater keeps calling `build-apk.sh` exactly as now. **Recommendation: the xtask**, with cargo-apk2 read for the details it already got right (v2 signing, `uses-feature`, ABI splits). ### The behaviours that are hard to get back Reading the Compose code for what a replacement must be able to express, rather than what it happens to look like: 1. **The transcript is one selectable body of text.** One `SelectionContainer` around the whole lazy list, so a selection runs from a reply into the tool output beneath it. The framework needs selectable read-only rich text across many rows, with the platform's selection handles and clipboard on the phone. 2. **Rich inline text**: markdown with links (one tap detector per text, not a node per link), inline code chips drawn behind the text, tables with wrapping cells and a sideways scroll, syntax-highlighted fences, ANSI colour in tool output, Nerd Font icon glyphs. Needs a text layout engine with spans, not just styled labels. 3. **A bottom-anchored virtualised list of variable-height rows**, paged in both directions (800-event pages, `HISTORY_SCREENS` measured in viewports), with a saved scroll anchor per session, "hold the edge nearest the tap" when a row expands (`holdTopEdge`, done in the layout pass so the wrong frame is never drawn), and rows keyed so that a run of tool calls stays one row while it grows. 4. **The soft keyboard**: the composer resizes with the IME, the guard against a stuck inset animation, drafts per session, autocorrect and suggestions from the phone's own keyboard. This is where most Rust frameworks fail on Android today; see below. 5. **Platform integration through the app model**: foreground service, notifications, share sheet, deep link, Keystore, camera, back gesture, edge-to-edge insets, local-network permission. 6. **Accessibility names on icon buttons**, which the bench scripts depend on (`ui-trace` taps by label). A framework with no accessibility tree also breaks the measuring rig. 7. **Measurable frames**: the debug render report, and a way to attribute a frame's cost to a widget on the real phone. ## What the experiments settled Twelve boxes, all closed between 2026-09-04 and 2026-09-05, and all deleted on 2026-09-08 now that their conclusions live in the code. One line each for what a later session must not re-derive; where a decision needs its reasoning, the reasoning is at the thing itself. **The framework track (E0-E5), against Masonry:** - **E0 -- toolchain.** NDK r29 (`29.0.14206865`) under `~/Android/Sdk`, cargo-ndk 4.x. Its API-level flag is `-P`; `-p` now means `--package`. - **E1 -- android-view's Masonry demo ran here**, on the GPU, with an accessibility tree and the phone's real keyboard -- but no autocorrect and no suggestions. The `android-view` rev this was measured against is pinned in `app-rust/Cargo.toml` with that history at the pin; `accesskit_android`'s detach-abort is mitigated in `iris/src/android/view.rs`'s `raise_if_enabled`, and advancing the version is not the fix. - **E2 -- a transcript in Masonry** found the framework-wide gap that blocked the comparison. It lived in `~/src/android-view/e2-transcript` and was never committed here. - **E3/E5 -- the Kotlin shell and the packaging xtask.** Both hold: `app/shellApp` plus the JNI bridge (now `app-rust`'s `shell` feature) posts a real notification and receives a real share, and `cargo xtask apk` packages an installable APK with `javac`/`d8`/`aapt2`/`zipalign`/ `apksigner` and one disclosed Gradle call, documented at `scripts/xtask/src/apk.rs`'s module doc. - **E4 -- the same screen on the desktop**, which is now `app-rust`'s `src/desktop` and the `ai-app-desktop` binary. **The iris track (I0-I5):** - **I0a -- iris is vendored at `iris/`**, history not carried, consumed by path, from `iris/iris` on gitea at `7b54aaf`. It goes back to its own repository once it has proved itself. - **I0b -- the nightly pin is dated, not rolling** (`rust-toolchain.toml`, one copy in `iris/` and one in `app-rust/`, because a pin applies per directory). Dated because a rolling channel moved `impl const Trait` to `const impl Trait` underneath the vendored tree and broke it unattended. - **I1 -- parley, plus a glyph atlas.** Both Iris's call. Parley addresses text by byte offset into one string, which is why the editing model looks the way it does. - **I2 -- iris runs on android-view**: the backend, the Gradle shell, insets, the back gesture and the full `InputConnection` bridge, with real Gboard suggestions. - **I3 -- the virtualised list.** Since renamed `LazySpan`, and scrolling has moved out of it into `ScrollController` -- `docs/SCROLL.md` is the current design, not this box. - **I4 -- accessibility names through AccessKit**, one flat tree with a synthetic `Role::Window` root and every *named* widget a direct child. Flat deliberately: nothing upstream of a named leaf needs a node. This is what lets `ui-trace` tap by label. - **I5 -- the transcript screen in iris**, with `FrameReport` for frame timing. Its descendants are `app-rust/src/ui` and every measurement rig in AGENTS.md. **Two findings from that period that are still load-bearing, kept where they belong rather than here:** iris's binding array does not survive real Android hardware (the measurement and the fix are `docs/TEXTURES.md`'s "Implemented, 2026-09-04"), and the emulator has no hardware Vulkan while its GLES *is* the host's real GPU through virgl (moved to the `this-machine-android` skill on 2026-09-08, with the `gpu-probe` output that established it). ## Findings that outlive the task that produced them Kept because the number or the constraint is what stops it being re-derived; the tasks themselves are done and deleted. ### The Android release profile, and where the APK's size went (2026-09-07) Iris asked why the iris bench APK was double the Compose one (20.6 MB vs 10.1 MB). It was almost all `libmain.so`, built with `panic = "abort"` and nothing else. Measured cumulatively, arm64 release: | profile.release | APK bytes | `.so` bytes | delta | |---|---|---|---| | `panic="abort"` only (baseline) | 20,678,956 | 18,546,488 | -- | | + `strip = true` | 16,435,156 | 14,302,688 | -4,243,800 | | + `lto = "fat"` | 15,751,212 | 13,618,744 | -683,944 | | + `codegen-units = 1` | 15,185,204 | 13,052,736 | -566,008 | | + `opt-level = "s"` | 13,326,076 | 11,193,608 | -1,859,128 | | + `opt-level = "z"` (**not adopted**) | 12,507,276 | 10,374,808 | -818,800 | | + platform fonts, no bundled Noto | 9,577,940 | 7,445,472 | -3,748,136 | `opt-level = "z"` was not taken: 0.8 MB is not worth the loop vectorisation on a renderer. Everything else is `app-rust/Cargo.toml`'s `[profile.android-release]` -- a profile of its own rather than `release`, so the desktop build is not also optimised for size. ### The fling stutter, and what a frame report could not say (2026-09-09) Iris, from her phone: *"I'm noticing some stuttering when flinging in particular. Harder to notice with my finger directly moving the scroll."* Her report had the fling phase at 3396 frames over 33.0s -- 103fps on a 120Hz screen -- with p50 6.3ms and 13.4% "late". **The report was not measuring what its own labels claimed.** Three things came out of chasing it, and the first two are corrections to the instrument rather than to the renderer: 1. **The swapchain acquire was counted as iris's CPU work.** `AndroidRenderer::draw` timed `queue.submit` + `present()` and called everything before it `redraw_to_submit`, but `get_current_texture` -- which *blocks* until the compositor frees an image -- sits in that span. An app comfortably ahead of the display spends most of every frame there, so a healthy fling read as several milliseconds of iris being slow. A frame is now three measured parts (`FrameParts`: `build`, `acquire`, `submit`), per phase as well as per run, because they do not divide the same way in every phase. 2. **Nothing could say a frame was never produced.** `late` counts frames that cost more than a budget, which is not the thing a reader sees: a frame that is late but drawn shows up on the next vsync, while a frame that never happens leaves the previous one on screen for two refreshes. `PhaseStats::missed` counts vsyncs nothing was drawn for, from the gap between consecutive frame times. 3. **The frame loop asked for its next frame after doing the work.** `Choreographer.postFrameCallback` schedules for the next vsync *after the call*, so any frame whose work ran past the vsync boundary registered too late for the next one and got the one after -- one frame over budget silently cost a second frame as well. It is asked for immediately after `tick_animations`, before the layout and the draw. And one that is about the animation rather than the report: **the fling was advanced on `Instant::now()`, not on the vsync the callback carried.** `do_frame`'s `frame_time_nanos` was discarded. Frames are *presented* on an even cadence whatever clock they are computed on, so sampling the spline at "whenever the callback got to run" moves the content by an uneven distance every frame -- a shimmer with no frame late enough to appear in any report, and it is exactly the asymmetry Iris described, since a drag's positions come from the finger's own timestamped samples and never had it. `sense::PointerClock` is now `sense::DeviceClock` and the view keeps **one**, anchored by whichever of a touch or a frame arrives first, so a fling is advanced on the clock its velocity was measured on. What the CPU side is *not*: `scripts/rigs/ui-profile`'s `frame_profile.rs` (AGENTS.md's rig list) puts iris's own per-frame work during a warm fling at p99 0.26ms, with only one frame in six laying anything out at all. The multi-millisecond spikes are first-pass only. **The result, from Iris's phone the same day: "now THAT is smooth. I couldn't actually see any lag myself."** With three corrections to what the report meant, found by reading that run against the bench's own timings: - **The frame rate never was the problem, and the first reading of it was wrong.** "103fps on a 120Hz screen" divided the fling phase's frames by its whole duration, which includes sixteen deliberate 300ms rests. Both runs sustained ~120.3fps through the motion itself. So the callback ordering was not costing frames -- what changed is the *clock*, which moves no frame count and is the whole point: an uneven sample of an even cadence cannot show up in any frame-time percentile. - **`missed vsyncs` counted idleness.** Every gap was treated as cadence, so the bench's own pauses read as stutter: 276 for sixteen 300ms rests, 2410 for twelve hundred 50ms keystroke gaps, 821 for four hundred 50ms stream gaps -- each within a few percent of the arithmetic. A gap now measures anything only if the frame before it had asked for another. - **`late` counted the vsync wait as cost.** A well-paced loop spends each frame blocked in the acquire, so its total sits at exactly one refresh period and every frame lands on the budget boundary. It is judged on `FrameParts::work` -- the total minus the acquire -- now. - **The refresh rate is the larger of what the platform claims and what the run sustained**, because each can only be wrong one way. `Display.getRefreshRate()` answered 60 for a run that drew 3405 frames in 33.1s, since a phone that varies its rate answers with whatever mode it is in when asked. And `FrameReport::sustained_frame_hz` is a *floor*: an app that cannot keep up says nothing about the panel. The first version of it took the fastest tenth of the gaps rather than the sustained rate and reported **88Hz for this repo's 60Hz emulator**, whose app manages 51 -- a budget no frame there could meet, invented out of the app's best moments, and caught only by running the corrected report on the emulator before shipping it. The two are printed together whenever they disagree. The signature of the fixed loop, from that run: `build p50 0.4ms, acquire p50 5.7ms, submit p50 1.7ms` -- four tenths of a millisecond of work and the rest of the refresh period spent waiting its turn. ### Streaming is where the frame time is now (2026-09-09) Measured after the fling was fixed, and it is not where it looks. `frame_profile.rs`'s stream run: folding an arriving event is 0.35ms and applying the diff to the widget tree is 0.41ms, while the frame that follows is 3.86ms on this desktop and 9.5ms of `build` on Iris's phone -- over a 120Hz budget on its own. 401 streamed events move the item count from 652 to 654, so nearly every one is a *delta into the same row*: the cost is re-laying out and re-shaping one growing markdown message on every delta. `fold_event`'s `items.to_vec()` per event was the hypothesis -- it is the exact shape of the Compose lesson in AGENTS.md's "Things that have bitten" -- and measuring it is what ruled it out. ### Incremental text: parley cannot, and it turns out not to matter (2026-09-09) Iris asked to investigate incremental text rendering and hoped parley supported it. **It does not, by design.** The crate's own docs: a `Layout` "supports re-linebreaking and re-aligning many times... but if the text content or the styles applied to that content change then a new `Layout` must be created". Its `LruCache` caches harfrust's per-font shaper data, instance and plan -- not shaped runs -- and its own `PlainEditor::update_layout` rebuilds the whole layout from the whole buffer on **every keystroke**. So there is nothing to adopt, and adding it would be upstream work in parley. **And the app already does the thing incremental layout would buy.** `RowBlocks::apply_delta` keeps one `TextEdit` per top-level markdown block and re-shapes only the block a delta landed in; re-splitting the markdown to find that block is 18µs at 18,000 characters and comparing the blocks is 470ns. Neither is the cost. **The 9.5ms is a bench-fixture artifact.** Measured with `frame_profile.rs`: - Re-shaping a block is linear in its length -- ~0.23ms per 1,000 characters on this desktop, so a message grown to 17,600 characters costs 4.1ms on its *last* delta and 842ms of shaping over the whole reply. - The fixture's streamed message is **14,888 characters in one block** -- a synthetic run-on paragraph with no blank line in it, so every delta reshapes all of it. That is the whole of the frame: 3.5ms of the measured 3.86ms. - Real replies are not like that. Over **7,706 top-level blocks from 3,675 real assistant messages** on this machine (block lengths only; no content left the machine): p50 147 characters, p90 449, p99 836, largest 1,580, and **nothing above 4,000**. Code fences are smaller still -- 170 of them, p50 126, largest 589. - At those sizes a live reshape is 48µs (p50), 208µs (p99) and 372µs (the largest block ever seen), or roughly 0.12-0.93ms on the phone. Comfortably inside a 120Hz budget, with no incremental anything. **So: incremental text is not worth building** -- and Iris agreed, with the fixture changed instead (2026-09-09: *"let's switch to new lines for the test, and also let's keep the single line around for stress + could be something to try to optimize later"*). What landed: - The streamed reply gets a blank line every 4-12 deltas, so it is 53 blocks with a longest of 502 characters instead of one of 14,888. The streaming frame went from p50 3.86ms / p90 8.65ms / worst 10.95ms to **p50 2.20ms / p90 5.90ms / worst 8.78ms** here. - The run-on message is kept as the first two backlog events, sized just under `text_cap`'s 16 KiB so it draws in full. The *streaming* pathology is kept in `frame_profile.rs` instead of the fixture, because it needs a growing block and iterating on it there costs a second rather than a two-minute phone run. - Adding it is **purely additive**: the random state is saved and restored around those two events, so every other backlog event is byte-identical. That is not cosmetic -- `phone_screen.rs`'s `a_long_press_and_drag_selects_text` replays a real recording at (300, 1000) and failed the first time round, when the insertion shifted what was under it. - `BACKLOG_COUNT` is 3202 now, in `generate.py`, `fixture.rs` and `BenchFixture.kt`. The split is by line index, so a stale copy opens a different half of the file. **The cap does not save a streamed reply, and this is worth knowing before optimising anything here.** Iris asked whether the newest message caps: it does not, deliberately -- `row::build_row`'s `cap` is `false` for the live tail because a row that grew while capped would appear to stop growing, and a reply that grows *past* the cap never gets caught either, since it grows through `apply_delta`. So a streamed block's shaping cost has no ceiling: at the measured ~0.23ms per 1,000 characters (about 2.5x that on the phone), a 50,000-character block would be ~29ms per delta and a 100,000-character one ~58ms. Real replies do not do this, which is why it is not urgent; nothing *stops* one doing it, which is why the stress case is kept. **What the remaining streaming cost is, and is not.** With realistic blocks the reshape is no longer the cost: layer 1's frame went to p50 2.20ms, spread over frames that added a block (p50 3.56ms, 56 of 401) and frames that did not (p50 1.94ms). Folding is 0.12ms and applying the diff 0.35ms. **But the emulator's `stream: build p50` did not move -- 10.4ms before the fixture change, 10.5ms after** -- while layer 1's CPU frame nearly halved. So most of a streaming frame on a real GPU path is something layer 1 builds and never uploads, and therefore cannot time. The candidate, and the arithmetic behind it: - The screen holds **11,568 primitives** by the end of the stream phase. - `UiRenderNode::update` re-uploads the *entire* instance and primitive arenas whenever `primitives.updated` is set, which a text change sets every delta -- about 370 KB per delta at 32 bytes an instance, before the primitive data itself. `ArrBuf::update` also **recreates the buffer** whenever its length changes, which adding glyphs does on nearly every delta, and a recreated buffer means a fresh bind group too. - The fling phase is the control that makes this convincing: it moves the same 11,568 primitives every frame through `move_offsets` -- a small buffer, no arena rewrite -- and its `build p50` is **0.4ms** against streaming's 10.5ms, on the same screen and the same content. So the next thing to look at for streaming is **uploading only what changed** rather than the whole arena, not anything about text. Splitting the reply into blocks was still right -- it is what makes the fixture representative, and it halved the CPU half -- but it was never going to move this, and it slightly increases the primitive count. ### The arenas upload deltas, and stopped being 11x too big (2026-09-09) Done, and measured by `scripts/rigs/ui-profile`'s `arena_churn` -- see AGENTS.md's entry for the rig and the numbers. The arithmetic above was right about the symptom and wrong about the cause being the upload strategy alone. Three things, in the order they had to be fixed: 1. **`ArrBuf` reallocated on every length change**, and a fresh buffer's contents are undefined, so a partial upload could not have been correct in the first place. It has a capacity now: geometric growth, never shrinking, and `update` says whether the `Buffer` identity moved so a caller can rebuild its bind group and force the whole range dirty. This alone took the glyph array from 95% re-uploaded to 3%. 2. **A redraw freed its primitives and pushed new ones.** Freed slots are not reusable until the end of the frame (a layer's draw order still names them), and nested provisional layout meant the arena's high-water was the *transient* push count: 17 million pushes across 401 deltas, and 127,443 slots for 11,569 live primitives, growing linearly with the transcript. A redraw now gets its old handles back as a recycle pool (`Painter::take_recycled`, `Primitives::recycle`) and writes into the slots it already holds. The arena is exactly the live count now, and the CPU frame fell from p50 2.20ms to 1.39ms as a side effect, since the freeing and draw-order renumbering went away. 3. **Nothing tracked which entries changed.** `util::Dirty` is a bitset per uploaded array, coalesced into ranges at a 1 KiB gap. Marking is O(1), and the read-back is one word per 64 entries. A `min..max` span was rejected on measurement (a frame's changes land in 5-20 scattered runs, so a span is nearly the whole buffer) and so was a `Vec` of indices (thousands of marks per frame would mean an allocation and a sort). The trap that only the rig could have caught: writing an entry is not the same as changing it. Recycling rewrote every glyph of every moved row with identical bytes, marking 73% of the glyph array against 0.6% genuinely changed. `PrimitiveVec::set` and `Primitives::set_instance` compare before marking. Layout can also write a provisional instance and restore it within one frame; `Primitives` remembers the pre-frame bytes and cancels that dirty bit when the GPU-visible result is unchanged. `arena_churn` prints both numbers so either gap cannot reopen unnoticed. **Layout has no measurement mode.** A widget is drawn provisionally only when its size cannot be known yet, and that retained drawing is moved into place. `Widget::size_hint(axis)` lets context-free wrappers such as `Sized` report an exact `Len`; a debug assertion compares every hint with the real draw result. If final allocation changes a child's size, `Painter::place` redraws it in that box. Otherwise placement is one move-offset write. Measured over the fixture's 401 streamed events, streamed-frame CPU p50 is 0.12ms, from 1.18ms before this layout change. Arena size and upload floors are unchanged. Pinned growth now uses the same subtree translation as scrolling. A container can retain a child-coordinate move slot through `Painter::set_child_offset`; `LazySpan` keeps retained rows in stable local boxes and changes that one slot when its anchor moves. It still walks the visible run to virtualise it, but unchanged rows no longer acquire new absolute primitive regions. Over the fixture's 401 streamed events, instance upload is **1.1% against a 1.1% floor**, from 71.9% against 71.8%; median instance bytes per frame are **1,488**, from 176,496. This is framework layout/rendering behaviour and the transcript screen contains no special case for it. ### The Android release profile is `opt-level = 3`, not `"s"` (2026-09-09) The table above was measured in bytes only. `"s"` costs the loop vectorisation and inlining a renderer runs on: over the same warm fling eight times, iris's own per-frame work is p90 0.15ms / p99 0.42ms at `"s"` against p90 0.09ms / p99 0.26ms at `3`. The arm64 release APK goes from 9,745,704 to 11,542,646 bytes (+1.8 MB) -- the same trade the table refused for `"z"`, one level further up. Iris raised it herself (*"I'd make sure it's in release mode"*); the build always was, and this was the part of "release" that was not about speed. ### Platform fonts, not bundled ones (2026-09-07) Iris: *"remove the font for now; just match what compose does."* The Compose app takes body text from `FontFamily.Default` and code from `FontFamily.Monospace` and ships no text font, only its Nerd Fonts icon subset. So `TextData::register_bundled_fonts`, the six `include_bytes!` Noto constants and `iris/core/assets/fonts/`'s `.ttf`s are gone. The reason this works at all: `FontContext::new()` was already finding the platform's fonts underneath the bundled ones -- `fontique`'s `CollectionOptions::system_fonts` defaults to `true`, with a real backend on both platforms iris ships on (`fontconfig` on Linux, `/system/fonts` + `/system/etc/fonts.xml` on Android). The **icon** font is the opposite case and is still bundled: a small, closed set of codepoints no system font is guaranteed to have (AGENTS.md's "Icons"). **Still unverified, and it is the half that can fail** (review R6, 2026-09-07): the bundled fonts originally existed because *"bold spans on a real phone rendered as blank gaps of the correct advance width"*, and the replacement was checked with CJK and emoji **on the desktop**. The fault was Android's font enumeration resolving a weight/style, so the desktop cannot answer it. Before the next phone build, look at a bold run and at `CLOSED_MARK`/`OPEN_MARK`/`UP_MARK` (U+25B8/BE/B4) on Iris's own device; the emulator's font set is not evidence for hers. ### Hit-testing does not consult the mask chain (review R2, 2026-09-07) Masks are applied in the fragment shader (`iris/core/src/render/shader.wgsl`); the CPU hit path (`UiRenderState::resolved_region`) does not look at `masks` at all. So a straddling row's clipped-away top is invisible and still tappable -- a tap on "Run benchmark" can land on an invisible link in the row behind it. Left deliberately: `docs/LAYOUT.md`'s mask redesign ("masks reference a drawn primitive instead of copying a shape") is where hit-testing gets the shape, and intersecting a chain in `resolved_region` now would be a second mechanism to unpick. ## The port, in order (decided 2026-09-05) The ordered plan for the rest of the app, decided here per Iris's standing "decide technical questions yourself" instruction -- no serious user-facing tradeoff is in play in the ordering itself. **Where the screens live** was settled by the 2026-09-08 reorganisation ("One app crate", below): every screen is a module under `app-rust/src/ui`, which holds a `Screen` enum and a back stack -- the direct equivalent of `AppRoot.kt`'s `when` and `MainScreen.kt`'s tab `enum` -- with each Compose screen becoming one `iris::widget` subtree. `src/desktop` and `src/android` are thin entry points that call into it, the way `AppRoot`/`MainActivity` today call into Compose screens they do not otherwise own. Platform-only code (the notification foreground service, the share target, the QR scanner, the Keystore-sealed token, deep-link enrolment) stays in `src/shell` + `app/shellApp`, since none of it is a screen `ui` could draw. Order is by **risk to the daily-use path**, not by screen count: the session screen is what the app is for and where every hard behaviour (paging, cache, keyboard insets, selection) already lives, so it goes first and on the phone as reachable code as soon as possible, before the lower-risk screens. Every step below assumes the `app/ui-sandbox.sh` fixtures (AGENTS.md's "The rigs") and the `this-machine-android` skill's facts (per-checkout AVD, `ui-trace` by accessibility name, GrapheneOS phone quirks, the `adb shell` quoting traps) apply unchanged -- read that skill before running any pass condition below that touches an emulator or a real device. - [x] **P0 -- the phone benchmark gate. Passed.** Asked for 2026-09-05, delivered and run on Iris's own phone; the reports are under `docs/bench/`. Both halves are still in the tree and are how a frame-time comparison is taken: the Compose `bench` build type (`app/`, `BenchFixture.kt`/`BenchRun.kt`) and the Rust `bench` feature (`app-rust`, `src/android/bench_client.rs`), opening the same checked-in synthetic transcript (`app/bench-fixture/assets/transcript.jsonl`, never a real one) with no server, driving the same scroll loop and streaming phase, and printing the same report fields. AGENTS.md's "The rigs" is the current description; `app-rust/build-apk.sh` and `run-bench.sh` are how it is run. The build source is this repository, but the Dev Updater publication is the separate `~/repos/ai-app-bench` repository, whose `iris-bench` component serves a committed APK with no build step. After an arm64 release build, replace its `iris/build/outputs/apk/release/iris-bench-arm64.apk` and push that repository; adding the component here or pushing only `ai-app-2` is the wrong delivery path. - [ ] **P1 — session screen parity.** **Started 2026-09-06, on Iris's word**: "just continue with the plan for now; try to move towards feature parity for the transcript screen so that the test can be more fair." So P0's "must pass before P1 starts" is lifted — the phone bench continues alongside, and parity is what makes its comparison fair. **Sub-order, by what the bench fixture exercises and Compose already draws** (tick and date each in place): - [x] **P1a — markdown block rendering parity.** Done 2026-09-06. Each top-level block is drawn in one of three frames (`ui::markdown::BlockFrame`) — plain, verbatim, quote — with fences and tables verbatim, headings scaled, and inline styling per span. `app-rust/src/ui/markdown.rs` is the code and its module doc the design. - [x] **P1b — tool-call cards and grouping.** Done 2026-09-06. `ToolRows.kt`/`ToolInput.kt` ported to `app-rust/src/ui/tool.rs`: a run of calls is one collapsible group, each card carries its state and summary, and the five `ToolState` values each have their own appearance. `tool.rs`'s module doc has what was chosen. - [ ] **P1c — history paging and jump-to-latest.** Wire `client::transcript_source` into `src/ui`: the opening page, paging back on scroll with the cushion measured in on-screen viewports (`HISTORY_SCREENS`, IRIS_TODO "Build (for the port)"), the `NothingLoaded`/empty/error states drawn distinctly (UI_RULES: design the unknown state first), `join_pages` at each seam, and a jump-to-latest control that pins to the newest end. Pass condition: the P1 pass condition below, against `ui-sandbox.sh` with `AI_SANDBOX_BIG_MB` and `--delay`. - [ ] **P1d — images, the session settings dialog, attachments, usage bar.** `SessionImage` thumbnails (the scaled image widget), the modal primitive and `SessionSettingsDialog`/ `UsageDialog`, `PendingAttachments` over the attachments route (`api.rs` gap), `SessionUsageBar` (the gauge widget). - [ ] **P1e — keyboard and insets behaviours** from AGENTS.md's "Things that have bitten", re-verified on the phone build: composer never left floating after the keyboard closes mid-stream, `adjustResize` + edge-to-edge together, one recomposition-equivalent per keyboard toggle (the `iris insets:` log line count). History paging backward (with the page-boundary healing `app-rust`'s `client` does not have yet, below), `TranscriptSource`-backed cache/server stitching, jump-to-latest, tool-call cards and grouping, the session settings dialog, composer attachments, and the keyboard/insets behaviours AGENTS.md's "Things that have bitten" names (the floating-composer bug, `adjustResize`, the `imePadding`-vs-raw-inset rule). This is the highest-risk step: it is the screen the app is used for, every hour of the day. **Kotlin it replaces**: `SessionScreen.kt`, `TranscriptList.kt`, `SessionSettingsDialog.kt`, `ToolInput.kt`, `ToolRows.kt`, `AskQuestion.kt`, `Compaction.kt`, `SessionUsageBar.kt`, `PendingAttachments.kt`, `Attachment.kt`, `Attachments.kt`, `SessionImage.kt`, `MemoryNote.kt`, `PeerMessage.kt`, `RawBlock.kt`, `CodeFence.kt`, `MarkdownLinks.kt`, `MarkdownPieces.kt`, `Markdown.kt`, `Bubble.kt`, `ScrollAnchor.kt`, `Drafts.kt`, `UsageDialog.kt`, `Chevron.kt`, `Dividers.kt`. (`src/ui` already covers the row/markdown/selection/composer core these sit on top of or beside.) **`app-rust`'s `client` needed, and what is not yet covered and must be ported first** (`CLIENT_CORE.md`): `TranscriptSource.kt` (deciding cache vs. server per page and stitching them — "not started"), `TranscriptItems.kt`'s `joinPages`/`healSplitMessage`/`adoptRun` (page-boundary healing — "not ported," and paging backward is exactly what exercises it), the markdown *block* model beyond syntax spans (headings/lists/tables/fences as distinct nodes — "not started," needed for `CodeFence`/`MarkdownPieces`' equivalents), and the attachments route (`/sessions/{id}/attachments` — "not covered" in `api.rs`, needed for `PendingAttachments`/`Attachment`). **iris widgets missing, → `IRIS_TODO.md`'s new "Build (for the port)" section**: row-level accessibility names and the tappable link / background-chip primitive (both already listed under I5's leftovers — this step is what needs them, not a new ask); a history-paging cushion measured in on-screen viewports rather than a row count (the `HISTORY_SCREENS` lesson in "Things that have bitten," which iris's `List` has no equivalent of yet); a scaled thumbnail/image widget for `SessionImage`'s in-transcript images; a modal/dialog primitive for the session settings dialog and `UsageDialog` (iris has none today — check before building a second one for P3/P5); a horizontal gauge/bar widget for `SessionUsageBar`. **Pass condition**: `app/ui-sandbox.sh`'s fixtures driven by `ui-trace record --do "tap '