RUST.md gets the report verbatim with what each of the four defects actually was, the tests that pin them, and two traps worth not re-finding (a fixed-coordinate tap that "failed" by 544px because it had toggled a tool group, and a layer-1 repro that only reproduces inside a `List`). IRIS.md and DECISIONS.md get the design half: one `Flinger` whose seam puts the sign convention and the content's end with the caller, a cancel as a first-class end to a gesture, and why a row is drawn twice on the frame its height changes. LAYOUT.md gains the two rules those turned on, since both govern the layout rather than this pass: padding works in any container and is an inset or an outset depending on how tight the parent's region is (Iris's own words), and a widget offered a box it does not fit is drawn again at its true box in the same frame rather than the next one. IRIS_TODO.md's "worked around in tool.rs rather than fixed here" is gone -- Iris, 2026-09-08: "There should never be workaround code." Two of the four entries are ticked; the two that remain are missing capabilities rather than defects being dodged, and each now carries a diagnosis of what building it costs instead of a workaround: an overflow ellipsis needs `TextBuffer` to have a displayed string distinct from its source (parley has none of its own, and every byte-offset consumer -- spans, `byte_at`, `Selection`, `apply_delta` -- moves if the buffer is truncated), and selectable tool-card text needs a register/unregister lifecycle across the three routes that rebuild a card, which is where a stale `Selection` handle panics. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
45 KiB
Decisions taken for Iris to review
Short list of design choices made by the design agent without asking, so they can be judged and reversed later. Detail lives in RUST.md (and IRIS.md for iris API changes); this file is only the summary. Newest first. Items marked DEFERRED are ones the agent chose not to decide alone.
2026-09-08 evening (the fling is shared; a cancel is not a release)
From Iris's four-item phone report; RUST.md's "2026-09-08 (evening)" box has the reasoning and the tests, IRIS.md the summary.
- A
Flingerthat does not know which way the content moves. Every scroll area flings now, on either axis, as Iris asked -- and the physics is one type shared byListandScrollrather than a copy each. The choice worth reviewing is the seam:Flingerowns the curve and the clock, and the caller owns the sign convention and where the content ends. Rejected: teachingFlingera direction, which would have to be told to it -- and being told is the same thing as not knowing, with an extra field to get wrong. - A cancel is a first-class end to a gesture, not an early release.
CursorState::cancelledis new state on the pointer sample, set by Android'sACTION_CANCELand the harness'sTouchAction::Cancel. Rejected: mapping a cancel toPressEndand having each widget decide what to suppress, which is what shipped and is why leaving the app flung the transcript. - A
DragGestureignores aCancelit caused. One gesture is driven by several widgets, so the widget that was pressed can be a "loser" on the frame its own gesture won. The test is whether the gesture's own capture id is the holder. This is what makes it safe for every widget driving a gesture to register the wholedrag_senses()set, which is now the rule without exception. List::placedraws a resized row twice in one frame. The old comment accepted a one-frame lag by analogy withScroll's content length. That analogy was wrong: a stale length only misplaces the next thing, while a stale box is drawn, because a background fills whatever box it is handed. The extra draw is bounded to frames where a row's height actually changed.
2026-09-08 (iris ships an icon font, and the drawn mark is deleted)
- Directed by Iris. Her question on seeing
widget::mark: "why does mark exist? The font should be working if it's working for compose and nerd fonts are bundled." It was not: the Compose app draws its icons from its own committed Nerd Fonts subset, while iris was setting the disclosure mark with bare Unicode geometric codepoints (U+25B8/25BE/25B4) out of whatever face the platform resolved -- an empty box on her phone, a dot on this VM. The 2026-09-07 entry below, which said "iris had no equivalent icon font to keep", is what left that gap: iris had no icon font because it had never had one, not because it needed none. - So iris now bundles the same kind of subset:
iris/core/build-icon-font.shwritesiris/core/assets/fonts/nerd_icons.ttf(992 bytes, three Material Design glyphs today),iris::iconnames the codepoints, andFamily::Iconsdraws them. This does not reopen the platform-fonts decision: body and monospace text still come from the platform, and an icon is the opposite case -- a small, closed, known set of codepoints, which is exactly the division the Compose app already makes. iris::widget::markis deleted (added earlier the same day). It drew a correct triangle, but only a triangle, and every further icon would have been another bespoke rasteriser. An icon as text also takes the size, colour and baseline of the line it sits in for free.
2026-09-08 (the emulator is a GLES machine, and Vulkan is verified elsewhere)
- Directed by Iris, carried out here: "make sure the setup uses GL for the android emulator and remove any vulkan requirements. That'll be tested through both the desktop version as well as my phone." So the emulator is settled as a GLES rig and nothing chases hardware Vulkan in it any more; the Vulkan path is covered by the desktop build and by her phone.
- Nothing had to be forced to make that true. Measured in the guest
the same day: the emulator has no hardware Vulkan at all (its only
Vulkan is SwiftShader, in software) and its GLES is the host's real RX
7900 XT through virgl at ES 3.1. iris's existing runtime fallback --
Backends::PRIMARY, no adapter, rebuild onBackends::GL-- already lands there, verified end to end with an ordinary (noforce-gles) debug APK. - The emulator and the phone therefore run the same binary, differing
only in what that binary finds. That is deliberate and worth not
undoing: a build flag that changed the backend would mean the thing
measured on the emulator is not the thing shipped.
force-glesstays, but only for pinning the backend on a machine that does have Vulkan (the desktop), and never for a phone build. - Every run now says which adapter drew it. The Android renderer logs
the full adapter line at startup the way the desktop already did -- only
the backend enum was logged before, which cannot separate
Glon the host's GPU fromGlon SwiftShader, or a phone's real Vulkan from a software one.run-bench.shprints that line before any number. - No Vulkan requirement was found in iris to remove.
device_limits()asks for nothing beyond wgpu's defaults (and zeroes the compute fields), neither backend requires a feature, and both probe rather than.expect()an adapter. What was removed was the documentation telling people to boot the emulator with SwiftShader Vulkan.
2026-09-07 (platform fonts, not bundled ones)
- Iris's own decision, carried out as directed: removed the 3.6 MB of
bundled Noto Sans/Noto Sans Mono TTFs from
iris-coreand load text from the platform's own font collection instead (fontique's system discovery, already on by default). Matches what the Compose app does -- it takes body text fromFontFamily.Defaultand code text fromFontFamily.Monospace, both platform-resolved, and ships no text font of its own. Rejected alternative (the one this pass had left open 2026-09-06): subsetting the bundled Noto Sans to Latin/common punctuation instead of removing it outright, which would have kept identical rendering across devices for a smaller (not zero) size cost; Iris chose to match Compose instead. .so-3,748,136 bytes (11,193,608 -> 7,445,472), matching the original 3.6 MB estimate. Fallback still lands on the platform's own tofu for a codepoint no resolved face has (checked with CJK + emoji on desktop) rather than blank space, so the UI_RULES unknown-glyph rule still holds.- Gap found, then closed same day: this fontique version's Android
backend never resolved the
Monospacegeneric family at all (confirmed on this checkout's emulator,mono=Nonein the startup diagnostic) -- two pre-existing bugs in fontique's ownfonts.xmlparsing stacked (an ordering bug, and a<family name="monospace">declaration whose<font>children the backend's parser never reads), not something this change introduced, but this change is what stopped masking it (the bundled mono font used to be registered ahead of the broken platform lookup, so it always won). Checkedlinebender/parley'smainbranch on GitHub: neither bug is fixed there, so there was no newer release to bump to. Fixed instead iniris-coreitself (TextData::patch_android_monospace, Android-only): reads/system/etc/fonts.xml's own"monospace"declaration for the font filename it names, then registers whichever of fontique's actually- scanned families owns that file as theMonospacegeneric -- the same authority Compose'sTypeface.MONOSPACEresolves through, without pinning an OEM-specific family name. Verified on this checkout's emulator:mono=Some("Droid Sans Mono"), and a screenshot showing the bench-fixture's code block and tool-card values in a visibly monospaced face beside sans body text; the desktopfontconfigbackend is unaffected (still resolves monospace correctly, confirmed unchanged). docs/RUST.md's "Platform fonts (2026-09-07)" has the full account.
2026-09-07 (a phone log reaches Iris through Dev Updater's own tab)
Supersedes the "how a phone log reaches Iris" entry below, same day.
Iris's call once the route was working: put it in Dev Updater properly
rather than smuggling the lines through ai-server's log.
-
The app exposes its own log on the device, and Dev Updater reads it there. A
ContentProviderat<applicationId>.devlog, one table of lines queried with?since=<seq>so a poll is incremental, plus astatusrow (held,dropped,newest_seq). Dev Updater's phone app polls it while the component's Runtime tab is open and forwards what is new to its own build machine, into that APK component's runtime log -- so the same tab renders both kinds and the history outlives the phone. No tunnel, no token, no second enrolment: the two apps are on the same phone.It is a contract, not a feature for iris. Written down in dev-updater's
README.md("An app's own log"), so any app that server delivers gets the tab by implementing it; the Compose app inapp/can do the same later. That is the reason it beat the route below on its second look -- the earlier one only ever worked for the one project that had a server, and put a phone's lines under a different component than the one they came from. -
Read access is
protectionLevel="normal", and that is a real trade.signatureis what this wants and is not available: Dev Updater and the apps it delivers are built on one machine but signed with different locally generated keys, so a signature permission would be held by nothing at all. Whatnormalcosts is that any app on that phone which requestsdev.updater.permission.READ_DEVLOGby name can read another app's dev log. Accepted because these are development builds on a development phone and the alternative was no log; stated in the manifest beside the declaration and in dev-updater's README so it is not rediscovered as a surprise. -
The provider polls rather than notifying.
notifyChangewas not implemented: the ring is filled by alog::Logbackend on whatever thread logged, and giving that a route to aContentProvidermeans plumbing a callback throughclient-corefor every platform. Dev Updater's contract therefore says it polls (about a second, only while the tab is open), which is what keeps implementing the contract cheap -- a provider that does notify loses nothing. -
What was deleted, so there is one mechanism:
client-core'slog_uploadmodule,POST /client-logonai-server, theAI_APP_LOG_HOST/_PORT/_TOKENbaking iniris/android-app/build.rs(which left that file with nothing to do, so it is gone too), and the uploader fields on both Android clients. Kept: the ring,RingLogger,install_process_logger, and the Diagnostics line counting what is held. The upload-status line there is now "devlog provider: content://" -- named from what the provider registered rather than composed from the package here, so a screenshot of that pane is evidence the contract is live and says which package's log it is.
2026-09-07 (how a phone log reaches Iris) -- superseded, see above
-
The app sends its own log to
ai-server, and Dev Updater shows it asai-server's runtime log. Iris has noadb/logcaton her phone, and Android forbids one app reading another's logcat, so the app has to carry its own copy and post it somewhere.POST /client-logonai-serverre-emits each line into that server's owntracingoutput; Dev Updater already runsai-serveras aManagedcomponent, whose stdout its own service script redirects to a file and reports throughGET /apps/{key}/components/{name}/logs?kind=runtime, which the phone app's log dialog already offers as a Runtime tab for aservercomponent. So no change to Dev Updater at all -- one route onai-server, and the client inclient-core.Rejected: posting to Dev Updater's own server (the first candidate, and what the entry above went on to build -- the estimate below was right about the work and wrong about it being too much). It would need a new authenticated write route on a TLS surface whose module doc says every route on it "is, or decides, the bytes that get handed to
REQUEST_INSTALL_PACKAGESnext"; a per-app device-log store; a change tocomponent_logsso an APK component can have a runtime log; a change to the phone app'shasBothKinds = component.kind == "server"gate and to whathasRuntimeLogsmeans on the wire; and -- the real cost -- a second enrollment for the iris app, since it has no CA or token for Dev Updater and Dev Updater mints tokens per device by QR. Five changes across two repos against one route, for the same line landing in the same viewer.Rejected: a share intent from a debug button (a log file in the app's external files dir, shared by hand). It works today and needs no server, but every line costs Iris a manual export and a message, which is the round trip through a person this was meant to remove. It is still the fallback when the tunnel is down, and GrapheneOS's own per-app log export already covers the crash case (that is how the
ToolInput.highlightedcrash was reported). -
The ring is in
client-core, not in the Android crate. A bounded in-memory ring (2000 lines or 256 KiB, whichever bites first) behind alog::Logbackend that forwards to whichever logger the platform already installed, sologcatand a desktop terminal see exactly what they saw before. The platform supplies only its own logger and its destination.Copy reportappends the ring to what goes on the clipboard, and flushes the uploader first. -
The destination is baked in at build time, from the build machine's own files (
AI_APP_LOG_HOST/_PORT/_TOKENplus the pinned CA) -- gone; the provider above replaced it. What is worth keeping from it is the reason it went: an APK good only for the server that built it cannot be built in this VM for Iris's phone, which is the case that mattered. all three or none, never two. The same trust boundary the transcript config and the Compose APK's CA already use: nothing secret is committed, and an APK is good for the server that built it. A build told nothing still keeps its ring and still copies it; the diagnostics pane says which of "not tried yet", "failing -- " and "no server configured" it is, because otherwise all three look like silence.
2026-09-06 (how a tool call looks, P1b)
-
A card that never got a result says "no result", in yellow, and it is a state Compose cannot say. A call that finished having printed nothing and a call whose turn was interrupted before anything came back both leave an empty output. Compose draws both as an ordinary finished call, which reads as a fact somebody established. There are five states now, each with a word and a colour: nothing at all for a call that worked, "running" (grey), "your turn" (peach, Compose's own wording and colour), "failed" (red), "no result" (yellow).
-
A failed call is drawn as failed, which needed a field on the wire.
is_erroris on the CLI'stool_resultand was being dropped; the server now carries it to the phone. Reversible, but the alternative is a card that says a call succeeded because it cannot tell. -
A group's cards do not each carry their own surface. Compose gives each card a fill and squares the corners where it faces a neighbour, so a run reads as one object broken into parts. iris has no per-corner radius, and -- more to the point -- a group built the way Compose builds it hit a framework layout defect that drew every card's text a card below its own box. So a group is one surface with its cards on it, separated by a small gap, and the 4dp inset Compose holds them off the edge by is gone. Worth revisiting once the layout defect is fixed (docs/IRIS_TODO.md).
-
A long tool output is capped at 80 lines or 4 kB with a "Show all N lines". Compose draws the whole thing, and gets away with it because its
Textinside aLazyColumnlays out lazily; here the output is one text widget and shaping a hundred kilobytes of it costs what the file editor's 32 kB limit was measured against. If iris's text gets cheaper, this is the number to move. -
A card's command is clipped, not pannable, and its summary line is clipped rather than ellipsised. Both are framework gaps rather than choices (
scrollable_onon a non-editable text draws nothing; there is no overflow ellipsis), and both are worse than Compose today. Named here because they are visible.
2026-09-06 (how a markdown block looks, P1a)
- A table is drawn as padded monospace columns, not as a grid. Your call to reverse. Compose draws a real grid: cells on a tint, each column with a 136dp floor, scrolling sideways when there are too many. iris has no grid widget, and building one would be a widget per markdown feature -- which is the thing the block model exists to avoid. In a monospace face a character count is a pixel width, so padding each cell to its column's width is alignment, the widths are still measured from the cells, and a table that is too wide pans sideways through the same mechanism a code fence already uses. The header is bold with a rule under it, and a long cell wraps inside its column (capped at 28 characters, which is what fits three columns across a phone). What it trades: no cell borders, and a table looks like code rather than like a table. If you want the grid, it is a new widget and it is a day's work.
- Three block frames, and only three. A heading, paragraph and list are plain text with spans; a fence and a table are a rounded panel that does not wrap; a quote is a bar with the text padded past it. Everything else markdown says is expressed in span styles, which cost no widgets and no layout nodes. So a new markdown feature is a span, not a widget.
- A list's marker is part of the text, so a wrapped item's second line returns to the left margin. Compose keeps it indented by giving the marker its own column. Doing the same here needs per-line indent in iris's text attributes; it is written down rather than done, because the list items in a real reply are usually one line.
- A link opens on a tap and not on the end of a drag. A press that panned the transcript past a link, or that held long enough to start a selection, does not follow it -- decided by the same gesture machine that decides pan-versus-select, so there is one rule rather than two that can disagree.
2026-09-06 (composer scroll and the streaming block model)
- A streamed message becomes a column of per-block widgets. Decided by
the design agent; recorded here because it is the shape of every message
on screen. A transcript row is one
TextEdittoday, so a streamed delta re-shapes the entire message through parley on every event -- the stream phase is the one place iris is behind Compose on your phone (p50 18.2ms vs 13.4ms). A row becomes a column of one widget per markdown block (paragraph, heading, fence, list, table) and a delta replaces only the last block, keeping every earlier block's layout. Rejected: splitting parley's layout at block boundaries inside one text widget (couples iris's text widget to markdown structure, and parley has no incremental API), and caching shaped runs per paragraph insideTextEdit(a second cache with its own invalidation beside the glyph cache). Chosen because P1's markdown block model is needed anyway, so the split happens once, inclient-core, and iris stays a text renderer. Status: designed, not built -- this pass spent its budget on the composer's three layout defects; docs/RUST.md has the design and the pass conditions. - The composer's overflowing text now scrolls on a finger, capped at six lines and clipped to the bar. Reverses the "still does not scroll" item below.
- A widget may not report a
dplength (see IRIS.md). A rule for widget authors, enforced by adebug_assert!; nothing changes for app code.
2026-09-06 (stale-primitives and touch-scroll pass)
- A vertical drag inside a focused composer now scrolls rather than
selects. Android's own
EditTextdoes this -- a vertical drag scrolls the field, and only a long press starts a selection -- so the platform decided it. What it costs: you can no longer drag straight down inside the composer to select several lines of what you typed; use a long press and then drag, or drag sideways. Say if that trade is wrong for you. Scrollgets a finger pan but no fling.Listflings; a scroll area does not, because it has no per-frame tick to animate one and the areas it wraps are at most a screenful (Android does not fling a six-line text box either). Easy to add later if a scroll area ever wraps something long.- The composer still does not scroll its overflowed text, though the
mechanism it needs is now in place. Wrapping the field in
.scrollable()was tried and reverted the same day:Scrollmeasures its content and container against the window, so inside theMaxSizethat caps the composer at six lines the two are in different spaces and the field pans itself entirely out of the bar (measured on the emulator with 474 characters in it -- the bar collapsed to its padding). Fixing that meansScrollmeasuring against its own offered box, which is a change to a widget the transcript and the bench shell both use, so it is its own piece of work rather than a rider on this one.
2026-09-06 (defect pass)
-
The keyboard-open diagnostics overlay is gone; the capture only logs now. It was added when
on_insets_changedwas not firing at all and there was no way to get a report off the phone. It fires reliably since the activity went edge-to-edge -- and what that looks like in use is a full-screen report covering the app every time the keyboard opens, with its own Copy/Close buttons sitting underneath the keyboard, so it cannot be dismissed (reproduced on the emulator this pass: twotap 'CLOSE'runs left it up). An interruption for something nobody asked for, over the app you are trying to type into. The namedDiagnosticsbutton still shows the same text on demand, and the newiris surface:/iris insets:log lines carry the lifecycle alogcatpull needs. Reversible:capture_keyboard_diagnosticsis still the one place this is decided, andPlatformHandle::show_diagnostics_overlayis still there. -
The bench shell's report pane is sized to its report, not to a share of the window. It held
.height(rest(1))beside the transcript'srest(2), so an emptyTextEditreserved a third of every screen -- which is what Iris's "the app does not start with keyboard spacing correct" screenshot was showing, with the composer two thirds down and black below it. It is.max_height(dp(260))now and sits above the transcript rather than under the composer, where it was eating the navigation-bar clearance. Cost: a filled report is clipped at 260dp rather than scrolling (aScrollthere drew itself off the top of the screen, sinceScrollpins to the end of its content and reports its content's full length to the parent -- worth fixing inScroll, not worked around here). "Copy report" andlogcatstill have the whole thing.
2026-09-05
-
iris no longer asks every device for compute-shader limits it never uses.
adapter.request_device(bothiris/src/android/render.rsandiris/src/default/render.rs) usedLimits::default()plus an override formax_buffer_size, andLimits::default()unconditionally requests desktop-tier compute limits (max_compute_workgroups_per_dimension: 65535, perwgpu_types) even though nothing iniris/iris-corecreates aComputePipelineor writes a@computeshader stage — confirmed by grepping the whole tree, not assumed. That crashedrequest_deviceoutright on the Android emulator's software GL path (EMU_GPU=software,--features force-gles): SwiftShader's GL reports itself as OpenGL ES 3.0, which has no compute shaders at all, so the adapter's real limit is 0 against the unconditional request for 65535 —RUST.md's "Software mode ... crashes for a third, different reason," 2026-09-05, earlier today. The same would happen on any real GLES-3.0-only Android device, not just the emulator. Fixed by a newiris_core::device_limits()(iris/core/src/render/mod.rs), shared by both platform backends so the two requests cannot drift, that zeros the sixmax_compute_*fields explicitly rather than switching to a downlevelLimitspreset —Limits::downlevel_webgl2_defaults()was considered and rejected: it also zerosmax_storage_buffers_per_shader_stage, andshader.wgsl's vertex stage reads fourvar<storage>buffers (rects, glyphs, masks, move_offsets), so that preset would trade the compute crash for a bind-group-layout one on the same downlevel hardware this is meant to support. No capability check or fallback path was needed since nothing is being disabled — the request is simply narrowed to what the pipeline actually uses.rigs/gpu-probe's own mirrored limits (it is deliberately its own crate, not a workspace member, so it cannot calldevice_limits()directly) were updated to match, and confirmIRIS DEVICE: okagainst this VM's own Vulkan and GL adapters. Not verified this pass: the specific SwiftShader-ES-3.0 crash this fixes, on-device — theEMU_GPU=softwarecold boot this needs would have force-restarted this checkout's emulator while another session was actively running its own app on it (com.example.aiapphad window focus at the time), so it was left for a pass when the emulator is free rather than disrupting that session. Everything reachable without the emulator is clean:cargo fmt/clippy --workspace --all-targets/test --workspace,cargo ndk build/clippyforiris-android-appwithforce-gles, andgpu-probeagainst this VM's own Vulkan and GL(ES 3.2, which still has compute and so would not have reproduced the crash even before this fix — not a substitute for the real ES-3.0 test). -
P0's Compose half is built and smoke-tested on the emulator — the
benchbuild type, the sharedapp/bench-fixture/transcript, and an in-process fake backend (BenchFixture.kt/BenchNetwork.kt) that answersTranscriptSource/EventStreamfrom an in-memory event log instead of a real server, so the fold and paging under test are the real ones. Full account, the smoke run's report, and what is deliberately left (the iris half, the real on-phone runs) are in RUST.md's P0 box. Not a decision to review so much as the gate itself now being runnable — flagged here because it is the first half of something Iris explicitly asked to see before P1. -
P0's iris half is also built and smoke-tested on the emulator, 2026-09-05. A new
benchCargo feature oniris-android-app, on top oftranscript-screen: the same checked-in fixture (include_str!, no asset pipeline needed), the same 24-swipe scroll loop animated throughList::scrolland the same 400-event/20s streaming phase throughfold_event, "Run benchmark"/"Copy report" as named accessible controls, and the same three added report fields (process CPU time, peak RSS, battery current) via direct JNI calls (bench_jni.rs::PlatformHandle) sinceandroid_viewhas noBatteryManager/ClipboardManagerwrapper of its own. One small public API addition to get there:AndroidAppState::platform_ready(IRIS.md), a default-no-op lifecycle hook handing an implementor aJavaVM+GlobalRefit can call Java through from any thread. Packaged with a newreleasebuild type oniris-android-app's own Gradle project (there was previously onlydebug), signed with the same keyapp/build-apk.shgenerates. Smoke run and the full report are in RUST.md's P0 box; not attempted this pass: the real on-phone runs and Iris's pass/fail call, which is the actual gate. -
The intermittent touch-scroll dropout is root-caused and fixed: a missed
ACTION_DOWNhit-test, not the previously-suspected coalesced firstACTION_MOVE. Diagnosed by temporary logcat tracing of every touch event,DragArbiterstate transition andSelection::dragdispatch (removed once confirmed), reproduced on this checkout's own emulator against a real sandbox session. The trace showed the actual mechanism: a gesture'sACTION_DOWNlands wherever the finger actually is, which is not guaranteed to fall inside the same row-local sensor region a laterACTION_MOVEin the same gesture lands in (a row's own padding/gap, or its non-selectable sender-name header, is pointer-transparent toiris::sense::CursorSense). When that happens, the widget that ends up handling the gesture never sawPressStart, soDragArbitersits inIdle— which answers every subsequent frame withUndecidedand has no way to tell "no press is happening" from "a press is happening but I missed its start," so it never recovers on its own for the rest of that gesture. One real trace showed exactly this: touchDown/Move/Upall delivered correctly, but zeroPressStartreaching the arbiter,state=Idleunchanged from first frame to last. Fixed at the call site that has the context to recover (iris::transcript_ui::selection::Selection::drag,iris/transcript-ui/src/selection.rs): a newDragArbiter::is_idle()(iris/src/sense.rs) lets it notice aPressingframe arriving with the arbiter stillIdle— which can only mean a missedPressStart, since aPressingsense requires the button to genuinely be down — and start the press there instead of where it was missed. Three new unit tests insense.rs'sdrag_arbiter_testsand one intranscript-ui'sselection::tests(the latter fails on the code before this fix). Commit follows. Not the same failure the earlier pass'sDECISIONS.mdDEFERRED item speculated about (a coalesced firstACTION_MOVEskipping slop detection) — that hypothesis is now ruled out; the arbiter's own slop/long-press logic was never wrong. RUST.md's I5 box, "Touch-scroll dropout root-caused, 2026-09-05" has the full trace. -
P0, a phone benchmark gate before any porting, asked for by Iris 2026-09-05: "before P1 I'd like to see benchmarks & also maybe stress test on my own phone ... If it doesn't match compose reasonably well then I don't think I'd wanna continue." Design (RUST.md's P0 box has the detail): the same embedded synthetic fixture in both apps with no server needed; the same scripted scroll loop then a streaming phase, run programmatically since the phone has no usable system tracing and no agent can drive it; the same report from both (frames, janky %, p50/p90/ p99, process CPU time, peak RSS, battery current where readable) with a copy button; the iris app under its own id and the Compose one as a new
benchbuild type with an id suffix, so neither replaces her production install; two arm64 APKs plus instructions delivered under~/host/bench/. The gate is hers: iris within a reasonable margin of Compose release on p50, p99 and CPU time, no crashes, no visible stutter. If it fails, the port stops. -
The rest of the port is one UI crate,
iris/app-ui, grown out ofiris/transcript-uirather than started beside it. It holds aScreenenum plus a back stack — the Rust equivalent ofAppRoot.kt'swhen— andiris/desktop-app/iris/android-appbecome thin entry points over it. Chosen over a fresh crate becausetranscript-uialready has the right generic shape (Rsc: HasEvents+Rsc::State: FocusHost) and theclient-core/event-modelpath dependencies every later screen needs, so growing it in place is the smaller diff. Platform-only code (notification service, share target, QR scanner, Keystore token, deep-link enrolment) stays in the E3/E5 Java shell (android-shell/+app/shellApp) rather than moving into this crate, since none of it is a screen. The Android APK is built bycargo xtask apk(E5), merging the app-ui cdylib into the E3 shell so there is one app rather than a demo shell plus a service shell.app/androidApp(the Compose app) stays untouched and is the baseline every step is measured against, until parity is reached (P7 decides the switch, and is itself a load-bearing decision left to Iris). Order is by risk to the daily-use path: session screen first (P1, where every hard behaviour already lives), then the shell merge and a real phone install (P2), then root tabs (P3), the explorer (P4), settings/enrolment (P5), desktop parity (P6), and the cutover itself (P7). Full plan: RUST.md's "The port, in order (decided 2026-09-05)". -
iris gets its own measured frame report, rather than waiting on a
dumpsys/gfxinfoanswer that cannot see aSurfaceView's GPU-drawn frames.iris_core::FrameReport(iris/core/src/render/frame_report.rs) times each frame's wall clock from the same pointrender()'s redraw starts to just afterqueue.submit+present()— the span Compose's own render report andgfxinfoboth count — into a fixed 4096-entry ring (no allocation per frame;report()is the only place that allocates, and only on a button tap). The report gives total frames, janky % over the same 16.7ms budgetgfxinfouses, P50/P90/P99 and the worst, plus a reset. Exposed the way the Compose app's copy-button report already is: two named controls ("Frame report", "Reset frame report") on the transcript screen, tappable by accessibility name viaui-trace, logging under this crate's fixedandroid_loggertag (iris-android-app) so a script can grep"iris frame report"the waytranscript-bench.shgreps"ai-app render report". The report's ownDisplayline says plainly that it measures up to thepresent()call returning, not GPU/compositor completion — wgpu'spresent()is not fenced against either, so presenting that span as "time to reach the screen" would be a measured-looking number that is actually inferred, which the standing UI rule forbids. -
ui-tracegains a hold-then-drag gesture, additive, inemulator-tools. Neither of its two existing actions can produce "hold stationary forLONG_PRESS, then move without lifting" —taphas no hold andswipe X1 Y1 X2 Y2 MSinterpolates motion across its whole duration from t=0. A new action presses, waits, then moves to a second point and releases as one continuous touch (rawsendevent/MotionEventinjection, extending whatever mechanism the existingswipealready uses), soDragArbiter's pan-vs-select rule (iris/src/sense.rs, already covered by 8 unit tests against a synthetic clock) can finally be driven on a real device instead of only in a test harness. -
Touch drag on a transcript row follows Android's own rule: a vertical drag pans the list immediately; a stationary press held 500 ms starts a text selection which further dragging extends; a horizontal drag while something is already selected extends that selection without the wait. One
DragArbiterper list decides it (iris/src/sense.rs). Chosen over a "text layer always wins" or "list always wins" rule because either loses one of the two gestures a reader expects. -
E4's desktop shape is a new
iris/desktop-appcrate: a winit window holdingtranscript-ui's screen beside a session list, talking to a realai-serverthroughclient-core. It enrols by pasting the sameaiapp://enroll?…link a phone scans (client-core::config::EnrolledServer) and keeps it owner-only under$XDG_CONFIG_HOME/ai-app-desktop/. The pinned CA is a path given on the command line, not baked in. Chosen so the phone and desktop share one enrolment format and no second one is invented. -
I5's Android integration extends
iris-android-app(I2's shell) behind a Cargo feature (transcript-screen), rather than a third shell crate. That project already has the Gradle module, theIrisView/MainActivityJava, and the JNI registration; the only thing a second screen needs on top is a differentAndroidAppState, the same axistabs_ui::build/transcript_ui::buildalready vary along on the winit side.tabs-screen/transcript-screenare mutually exclusive and each pulls in only its own deps, so the plain tabs build (I2/I4) is untouched. -
Order of remaining work, updated 2026-09-05: the two in-flight pieces and I5's Android integration are all done; next is giving iris its own frame-timing report so item 3 below can be decided by a number.
-
DECIDED by Iris, 2026-09-05: iris is the app's framework; Masonry was the calibration. Her words: "I think iris definitely makes more sense based on the limitations we've found." The limitations: Masonry has no touch scroll on Android (E2), no per-span rich text and no cross-row selection on the pinned commit (E2), and its keyboard bridge is a TODO (E1); iris carries the same screen under the Compose baseline on the host GPU (p50 15.0 ms against Compose's 20.0 ms, RUST.md's I5 box). What follows: the E-steps are closed as calibration, and the port proceeds on iris — screens, the shell (E3/E5), and
client-coreunderneath. The item below is kept as the record of what she decided from. -
Was DEFERRED — whether to commit to iris over Masonry for
ai-app. Updated 2026-09-05 with the clean comparison the recommendation wanted: same sandbox session content, same emulator,EMU_GPU=software, one session. Headline numbers (RUST.md's I5 box, "Clean scroll comparison, 2026-09-05," has the full table and every caveat):app build frames janky % p50 p90 p99 worst Compose (in-app report) debug 1102 99.0% late 33.8ms 50.6ms 79.5ms -- Compose ( dumpsys gfxinfo)debug 1499 21.15% (95.66% legacy) 32ms 48ms 150ms (p99) -- iris ( FrameReport)release 299 94.65% 79.1ms 98.6ms 117.8ms 212.6ms iris ( FrameReport, repeat)release 233 94.42% 109.3ms 130.8ms 147.1ms 150.5ms Not a clean apples-to-apples reading, stated plainly rather than smoothed over: iris had to be built release (debug
SIGSEGVs on this emulator's Vulkan loader, I4's finding) against Compose's mandated debug build, so this asymmetry likely understates iris's gap rather than the reverse; the three frame-time sources measure different things (Compose's own phase accounting vs. Android's HWUI deadline-miss definition vs. iris's redraw-start-to-present window, the last of whichdumpsys gfxinfocannot see at all for iris'sSurfaceView); and both figures are emulator numbers under software rasterisation, which Compose's own in-app report shows already costs 20-34ms/frame inswap+gpualone under this GPU mode, so a same-mode iris number well above 16.7ms was expected going in for either app. A second pair under-gpu hostwas not taken this pass. The earlier session's suspected intermittent touch-delivery dropout was not reproduced this pass — the zero-frame results this time traced to this pass's own script bug (acdthat changed which emulatorui-tracetargeted), not the emulator; a CPU-load rise during the gesture was observed by a sampler running throughout, but did not correlate with any failure, so the original candidate is neither confirmed nor ruled out. The choice in front of Iris, updated: decide now on the structural-plus-functional case already made (iris works end-to-end where Masonry's scroll gesture doesn't exist at all on Android) plus this table — reading the two build profiles and three jank definitions with the caveats above rather than as a single number — or ask for a same-profile, same-GPU-mode rerun first. RUST.md's I5 box has the full account.Updated 2026-09-05, the
-gpu hostpair taken. Real GPU rendering (force-gles-- the default Vulkan backend has no adapter at all under plain host-GPU boot, confirmed by the exactwgpuerror) reverses the software-mode shape:app build GPU mode frames janky % p50 p90 p99 worst cpu p50 gpu-wait p50 Compose (in-app report) debug host (virgl) 1268 96.4% late 20.0ms 28.4ms 37.7ms -- -- -- iris ( FrameReport), best of three, 2026-09-05release, force-gleshost (virgl) 439 46.24% 15.7ms 23.3ms 31.2ms 57.4ms 1.2ms 13.2ms Under real GPU rendering iris's median frame is faster than Compose's, not the 2-3x-slower shape the software-mode table shows. A new split inside
FrameReport(redraw-to-submit vs. submit-to-present, commite2a1fad) says why: iris's own CPU work per frame is a median ~1ms -- almost the entire frame is time spent handing the frame to the driver, not in iris's layout/text/primitive code. This is consistent with the earlier software-mode gap being mostly SwiftShader's CPU rasterisation cost rather than an iris-specific slowness. Still not proof, and now closed as unanswerable rather than merely untaken: a same-mode softwareforce-glesrun to isolate the backend was retried 2026-09-05 after fixing the compute-limit crash the first attempt hit, and hit a second, structural wall instead — SwiftShader's ES 3.0 GL path has no storage-buffer capacity at all, andshader.wgslreadsvar<storage>buffers unconditionally, so reaching that path needs a shader rewrite, not a limits fix (RUST.md's I5 box, "The three remaining I5 verifications, closed 2026-09-05," item 2). The intermittent touch-scroll dropout this pass also reproduced is root-caused and fixed as of the same date (a missedACTION_DOWNon a row's padding/header leftDragArbiterstuck inIdle); three cleaniris-scroll.shruns post-fix each scrolled all 24/24 swipes, replacing the single-attempt 62-frame reading this table used to carry. RUST.md's I5 box, "Where iris's frame time goes, 2026-09-05, the-gpu hostpass," and "The three remaining I5 verifications, closed 2026-09-05," have the full account. The iris-vs-Masonry choice itself is still Iris's to make.
2026-09-07: the enrolment link carries the CA, so an APK need not be built where its server runs
Problem. Every phone build pinned the CA of the machine that compiled
it -- the Compose app from GeneratePinnedCert, the iris app from
build.rs reading $XDG_CONFIG_HOME/ai-app/certs/ca.pem. That is fine
while the two are the same machine and impossible when they are not, which
is exactly the iris client's situation: cross-compiled in this VM,
delivered to a phone, run against ai-server on the host. Baking the
host/port/token as well made it worse -- a token in a built artifact.
Decided: the CA rides in the enrolment link, as &ca=<base64url of the DER> (wg_app_link::enroll::ca_param), optional and per mint. The app
that opens the link pins what the link said, and an APK built anywhere
works against whatever server it is pointed at.
Two alternatives were worked out and rejected.
- A CA fingerprint in the link, pinned at the TLS handshake. The
smallest link (43 more characters) and the strongest shape, but
ureq3.4 exposes no hook for a customrustlsServerCertVerifier: itsTlsConfigbuilds theClientConfigitself, so this needs a hand-writtenConnectoron theunversionedAPI andrustlsas a direct dependency ofclient-core. A lot of machinery in the one crate that must stay light. - A fingerprint in the link plus an unauthenticated
GET /ca.pem. Small code, but it needs a first connection with verification disabled, and it breaks a documented, tested posture --auth.rs's "gates every route with zero unauthenticated endpoints", which is a load-bearing decision rather than an implementation detail. Not something to change silently for this.
What it costs, measured rather than guessed: on this project's P-256
CA the link goes from 89 bytes to 652, and print_enrollment's terminal
QR from 45x23 to 93x47 characters. That is why the parameter is the
minter's choice per call: ai-server passes it (its iris client needs it),
dev-updater passes None (its app is built on the machine it talks to,
and its QR stays scannable in an 80-column terminal). The URI printed under
the QR is the fallback either way, and is the path Dev Updater's Enroll
button already uses -- it opens the link with ACTION_VIEW, so Android
offers whichever apps registered the scheme, which needed no change here.
The CA is a public certificate, so putting it in the QR leaks nothing the token did not already: photographing the terminal still costs exactly the token, which is rotatable.
The log upload's destination is moot, so it is not wired to this. On
the same day Iris decided Dev Updater will read an APK's runtime log from
an on-device ContentProvider instead, which removes log_upload,
POST /client-log and the AI_APP_LOG_* baking altogether -- so the
enrolment landed without touching any of them, for that change to delete
whole.