# iris: the log of how it is being built For Iris to read on her own time. An entry is anything **major**: a new capability or widget, a design decision and what it was chosen over, a mechanism that changed shape, a defect whose root cause says something about the framework -- and the public-surface changes a widget or app author would notice, which is all this file used to hold (widened on Iris's instruction, 2026-09-08: "any major additions or design things should be added there, not just public API stuff"). Small and trivial things still stay out. An entry gives the date, what changed, why, and a short before/after where it helps judge the change without the session that made it. Newest first. ## 2026-09-08: what a cancel means, what a row's box is, and one fling for every scroll area Iris's second 2026-09-08 report, from the bench on her phone. Four items, and each turned out to be a rule stated in one place and missing from its siblings rather than a special case. **A gesture the *platform* takes away is a cancel, not a release** (`CursorState::cancelled`). Android's `ACTION_CANCEL` used to take the same arm as `ACTION_UP`, so the system's own swipe up from the bottom edge to leave the app arrived as a flick released at speed: the transcript flung while the app was in the background, and came back somewhere else. A cancelled sample now hands `CursorSense::Cancel` to the capture holder *and* every widget still tracking the press, clears both, and derives nothing else from that sample -- no tap, no selection, no fling. That is the same sense a widget already gets when it loses a capture race; what is new is that the platform can raise it, and that the *winner* hears it too when the platform is the one cancelling. **A `DragGesture` ignores a cancel when it is the one holding the capture.** A cancel goes to every pressed widget that did not capture, and one gesture is routinely driven by several of those: a transcript row's text block feeds the shared gesture that captures under the *list's* id, so the block is a "loser" on the very frame its own pan committed. `Cancel` means "somebody else won", so the question is whether the holder is us -- and now it is asked. With that, a row's block registers the whole `drag_senses()` set, which is what the doc on that set has always said a widget driving a gesture must do; it was the one place that did not, and it is why panning a code fence sideways and then tapping made the transcript jump. **A row is drawn at the box its own height implies, in the frame that height changes** (`List::place`). A row is offered its *cached* height so that an unchanged row takes `draw_inner`'s cheap path; a `.background(rect(..))` fills whatever box it is handed. So on the frame a row changed height its text laid out at the new height and its background painted at the old one -- collapsing or opening a tool card looked closed while its text was there, then open while it was not. When the measurement disagrees with the offer, the row is now drawn again at its true box. The bottom-anchored half had a `reposition` for this, which writes an offset and never a size, so it could not fix it either: the same rule, applied to one member of a set of two. **Every scroll area flings, on either axis** (`iris::sense::Flinger`). The fling was `List`'s alone -- the curve, the clock, the incremental delta, Compose's two release thresholds -- and a `Scroll` dropped its released velocity on the floor, with a comment explaining that the areas it wrapped were only a screenful. That stopped being true the moment a code fence became one. `Flinger` is that machinery as a type both use; what it deliberately does not know is which way a positive delta moves the content or where the content ends, because a `List` and a `Scroll` answer those oppositely. The caller applies `tick`'s delta in its own convention and calls `stop` at its own wall. `Scroll::drag` now answers whether it started a fling, which is what `scroll_area` needs to call `UiData::animate` -- the same split `List::fling` already documented, for the same reason: only the caller can reach the frame loop. **Removed, not worked around**: `tool.rs` no longer flattens its two `Span`s into one, so a tool group holds its cards 4dp off its own edge again. The defect that shape was avoiding -- "a `Span` of `Pad`ded children inside another `Span` places those children a slot out of step" -- is not reproducible on 2026-09-08, checked both with a headless render and with a new layer-1 test. ## 2026-09-08: a gesture can be cancelled, and the pointer belongs to the input handler Two changes to how a drag ends, from defects on Iris's phone (a code fence panned sideways made the transcript jump on the next tap, and made the fence itself snap back). **`CursorSense::Cancel`, and `GestureOutcome::Cancelled`.** Taking pointer capture cuts every other widget off from the press completely -- no `PressEnd`, no `Drop` -- so anything else tracking that press was left with a gesture open at an origin belonging to a finger long gone, and the next touch anywhere was measured from it. A widget that loses a capture race is now told, exactly once. It is a separate sense from `Drop` deliberately: `Drop` means "your gesture finished" and callers act on it (a fling, a tap, a link followed), which is precisely wrong here. **`CursorSense::drag_senses()`** is what a widget driving a `DragGesture` registers -- the frames plus `unclick`, `Drop` and `Cancel`. Both ways a gesture can end, stated once rather than remembered per call site; forgetting `Drop` is what left a `Scroll` panning from a stale position. **The pointer's state left `UiRenderState`.** `capture_pointer`, `release_pointer` and `captured_pointer` are gone from it. Capture and the pressed set are `PointerInput` -- the cursor senses' `Event::Global`, a new associated type for state an event owns that belongs to no single widget -- held by the event manager that runs the dispatch and reached by `&mut`, with no lock anywhere. A handler asks through `ctx.data.pointer` (`PointerRequests`: `capture(id)`, `release()`, `holder()`). // before -- interior mutability on whatever structure was reachable ctx.data.render.capture_pointer(id); // after ctx.data.pointer.capture(id); `DragGesture::handle` and `Scroll::drag` take `&PointerRequests` where they took `&UiRenderState`. `task_on` also lost a `Data: Send` bound it never needed -- the future it spawns never sees the event's data, and that bound was the whole reason the pointer state had been behind a `Mutex`. ## 2026-09-08: `mark(dir, dp, colour)` -- a drawn triangle, and a scroll area's opening edge **`iris::widget::mark`** draws a filled, antialiased triangle pointing along a `Dir`, at a size in dp. It replaces the disclosure codepoints U+25B8/25BE/25B4, which were a bet that the platform's fonts have them -- once iris stopped bundling its own faces, Iris's phone drew an empty box. It rasterises one oversampled bitmap into the ordinary texture path and scales it into the box asked for, so no new primitive was needed and it is correct at any density. **`scrollable_on` now opens at the beginning of its content, and `scrollable_to_end(axis)` is the other one** -- pinned to the end and staying there while the content grows, which is what a composer wants and what everything did before. A code fence was opening at the end of its longest line, in the middle of a word. `Scroll::new` takes the edge as a third argument rather than deciding for its caller. The design point behind that bug is worth more than the bug: `Scroll` held its content's length as an `f32` that was `0.0` both for "there is nothing here" and for "I have not drawn yet". Those lead somewhere different, and the code could not ask which it had -- so the first frame's clamp computed a scroll range of zero, read `amt == len` as "sitting at the end", and pinned itself there. It is an `Option` now, and the clamp declines to answer a question it cannot yet answer. Any measurement iris caches from a previous frame has this shape (LAYOUT.md section 4's one-frame lag is the general case), so the rule is: give the unmeasured state its own value, not a plausible number. ## 2026-09-08: masks have a shape -- `.masked_by(shape)`, and clipping applies to touch A mask no longer carries a rectangle. It carries **the slot of a primitive already drawn**, and the fragment stage evaluates that primitive's own coverage at each masked pixel and multiplies it into the alpha -- the same rounded-rect SDF the primitive itself is drawn with. Nothing about the shape is copied, so a rounded container's corner and the corner its content is cut to cannot fall out of step, and nested masks multiply rather than intersect: a pixel inside two feathered corners is dimmed by both. // before -- the mask clipped to the padded box, the rounding was // only painted behind it, and the two knew nothing of each other field.scrollable_on(Axis::X) .masked() .pad(dp(FRAME_PAD_DP)) .background(rect(fill).radius(dp(FRAME_RADIUS_DP))) // after -- one rect, drawn and clipped to field.scrollable_on(Axis::X) .pad(dp(FRAME_PAD_DP)) .masked_by(rect(fill).radius(dp(FRAME_RADIUS_DP))) `.masked()` is unchanged for callers and still clips to the widget's own box; under it, it now writes an undrawn rect primitive and points the mask at that, so square-cornered clipping is the same mechanism rather than a special case. `.masked_by(shape)` draws `shape` behind the content, in its own layer, and clips to the first primitive it drew. There is no radius or shape argument anywhere -- that is the point. **A press now has to be inside the shape, not just the box.** A corner the container rounded away is not there to be tapped, which needed the coverage function on the CPU as well as in the shader; `iris/tests/mask_sdf.rs` runs the shader's own text against the Rust one over a grid of points so the two cannot drift apart. One limit worth knowing before reaching for it: **a mask's shape must be a rect**, asserted by name. Clipping to a glyph or an image would need, respectively, a CPU-side alpha plane for the hit test and a bind-group switch the fragment stage cannot make. The shader has the branch where either would go. ## 2026-09-07: iris runs on a GLES-only Android device, and reports the renderer it cannot build `AndroidRenderer::new` asked wgpu for `Backends::PRIMARY`, which does not include `GL`. A device that offers a Vulkan driver with no adapter behind it -- this checkout's emulator -- therefore had no adapter at all, and the `.expect` on that turned into a crash loop with nothing on screen. It now probes for a `PRIMARY` adapter first and falls back to `Backends::GL` when there is none, so **Vulkan still wins wherever it has an adapter** and nothing changes on a phone. The probe deliberately runs on an instance that never touches the window: an Android window can be connected to one graphics API only, so an instance carrying both backends lets Vulkan claim the window and leaves the GLES surface unusable. That is why this is a second instance rather than one wider `Backends` value. The other half a caller sees: `AndroidRenderer::new` already returned `Result`, and now **every** way it can fail goes through that -- no surface, no adapter, no device, as well as the bind-group validation failure it was originally written for. `surface_changed` puts that string on screen and in the log ring instead of aborting. ## 2026-09-07: `VelocityTracker` takes positions, not deltas A flick released at the wrong speed because the tracker averaged. It now does what Compose's touch scrolling does, and that changes what a caller feeds it. // before -- one frame's motion tracker.add_sample(dy, now); // after -- where the finger was tracker.add_position(pos.axis(axis), now); `VelocityTracker::velocity` is a port of Compose's `VelocityTracker1D` with `Strategy.Lsq2`: a degree-2 least-squares fit through the last 20 positions, differentiated at the newest sample, with Compose's 100ms horizon, 40ms stopped-gap and three-sample minimum. Positions rather than deltas because a fit needs points on a curve -- Compose itself throws on differential data for this strategy. Three consequences a caller sees. **A gesture with fewer than three samples answers `0.0`**, where the average answered a number from two; that is Compose's answer too, and on the phone a 120Hz flick delivers four or five. **A finger that rests for more than 40ms before lifting answers `0.0`** rather than flinging at the speed it arrived with. **`add_position` must be called in time order** -- the same debug assert as before, now load-bearing for the fit's x-axis. Also new: `VelocityTracker::samples_display` (the held samples as `t_ms:position`, printed by `DragGesture` at debug level so a flick reported from a phone can be replayed), `DragArbiter::axis`, and `sense::MAX_FLING_VELOCITY_DP_S` (8000, `ViewConfiguration`'s own). `List::fling` now applies that maximum against its own density and ignores anything at or under 1px/s, which is Compose's pair of thresholds exactly -- there is deliberately no 50dp/s minimum, because Compose's scrolling never consults the one in `ViewConfiguration`. ## 2026-09-07: `client-core` carries the app's own log Not iris itself but the crate beside it, and it is a new public surface an app author will use: `client_core::log_ring`. Because Iris's phone has no `logcat`, an app now keeps a bounded copy of its own log and hands it to Dev Updater on the device. Before, an app installed a platform logger and that was the end of it: android_logger::init_once(config); // Android // nothing at all on the desktop After, the platform's logger becomes the *inner* logger of a ring that records everything alongside it -- `logcat` and a terminal see exactly what they saw before: client_core::log_ring::install_process_logger( Box::new(android_logger::AndroidLogger::new(config)), LevelFilter::Debug, )?; let ring = client_core::log_ring::process_ring(); // 2000 lines / 256 KiB ring.to_text(); // for a report ring.summary(); // "1801 lines held, 12 dropped, last 20:09:24" // and, for whatever hands the log out of the process: let (lines, next) = ring.since(cursor); // inclusive of `cursor` ring.newest_seq(); // None for a ring nothing was written to `process_ring` is a deliberate process-global, unusually for this project: `log` already has exactly one backend per process, and a ring passed around as a parameter would be a second answer to "which lines exist". **Amended later the same day.** `client_core::log_upload` and `ai-server`'s `POST /client-log` are **gone** -- an app no longer sends its log anywhere. It exposes it on the device instead, and Dev Updater reads it there: on Android that is a `ContentProvider` at `.devlog`, which is Dev Updater's own contract (its `README.md`, "An app's own log") rather than anything iris-specific. `LogRing::newest_seq()` is the one addition that went with it: a reader holding a cursor uses it to notice the process **restarted**, since the ring is in memory and a new process starts again at sequence zero. The reasoning and the rejected alternatives are in docs/DECISIONS.md, 2026-09-07. ## 2026-09-07: `TextData` no longer bundles a font Iris's call: "remove the font for now; just match what compose does." `TextData::default()` used to embed six Noto Sans/Noto Sans Mono `.ttf`s (3.6 MB, `include_bytes!`) and register them ahead of the platform's own fonts in the `SansSerif`/`Monospace` fallback lists. That registration is gone; `TextData::default()`'s signature is unchanged, but what it produces now depends entirely on `fontique`'s platform discovery (already on by default, previously shadowed) -- Roboto/Roboto Flex on Android, whatever the desktop's fontconfig resolves on Linux. No caller-visible type or method changed, but every consumer of `iris-core` text now renders with whatever the host platform's fonts are, not a fixed bundled face -- worth knowing if you were relying on pixel-identical text across devices. `.so` shrank by 3.75 MB. One real gap surfaced by the switch: this fontique version's Android backend never resolves the `Monospace` generic family (a fontique ordering bug, not new in this change), so `Family::Monospace` text falls through to the same face as `SansSerif` on Android rather than a true monospaced one -- still visible, not blank, just not monospaced. docs/RUST.md's "Platform fonts (2026-09-07)" has the full account. ## 2026-09-07: a headless harness, replayed touch, and physical-pixel desktop layout Layer 1 and 2 of docs/RUST.md's "Three test layers". **New: `iris::harness`** -- a screen driven in-process with no window, no compositor and no GPU, on a clock the caller advances. `Harness::new(size, density)` gives you an `Rsc`, a `UiRenderState` and a state that implements `FocusHost`/`OpenUrl` by *recording* what the platform was asked for (`keyboard_shown`, `opened_urls`) rather than doing it; `frame(t_ms)`/`frames_until(..)` run frames, `touch(action, pos, t_ms)` feeds one pointer sample the way Android's `on_touch_event` does, and `replay(&TouchScript)` runs a whole recorded gesture. `TouchScript` parses a plain `t_ms action x y` file (`down`/`move`/`up`/`cancel`), so the batched 120Hz flick shape your phone actually delivers is a file that `cargo test` can replay -- something the emulator cannot produce at all. **New: `List::fling_velocity() -> Option`**, what the release measured, readable where it landed rather than by re-timing the gesture. **Changed: `List` starts a fling's curve at its first `tick_fling`, not at the release.** The only clock it reads is now the one its driver hands it; in a running app the difference is at most a frame. **Changed: the desktop backend lays out in physical pixels with a density, exactly as Android does.** `iris::default::content_scale(window)` is the desktop's `content_scale` -- winit's scale factor, overridable with the `IRIS_SCALE` environment variable -- and it now feeds `UiRenderState::set_density`/`TextData::density` instead of dividing coordinates into a separate "logical" space. That division had `UiRenderState::resize` (physical) and the window uniform (logical) disagreeing on any display whose scale factor is not 1.0, and rasterised glyphs at one resolution to display them at another. `Input::event` lost its `scale_factor` parameter as a result, and `DefaultUiState:: window_size()` now answers physical pixels. On a 1.0 display nothing changes. The override is what lets `run-headless.sh --phone` open a window at your phone's own 1080x2424 and 2.55. ## 2026-09-07: the fling curve was the identity function You said the fling "seems to just be linear velocity with an abrupt stop." It was, exactly: `android_fling_spline`'s lookup returned `t` for every `t`. Two halves of AOSP's spline build loop had been transposed, which made its two tables identical, and the lookup interpolated one against the other -- which reduces algebraically to `t`. So a fling coasted at its release speed for the whole (correctly computed) duration and stopped dead at the end of it. Ported exactly now from `OverScroller.java` and Compose's `SplineBasedDecay.kt`, which agree line for line. One public addition: **`FlingCalculator::velocity_at(velocity, elapsed) -> f32`**, beside the existing `position_at` -- AOSP's `mCurrVelocity` and Compose's `FlingInfo.velocity`. It is what makes "is this decelerating" answerable rather than inferred, and it is what `List::tick_fling`'s new `iris fling tick:` debug line reports each frame. The lesson worth keeping, since it cost two builds on your phone: every test the calculator had compared it with itself -- monotonic, correctly signed, integrates to the closed form, per-tick deltas non-increasing -- and **all of them pass on a straight line**. The numbers now come from `iris/benches/fling_spline_reference.py`, a separate hand transcription of the two sources, checked in beside the tests. ## 2026-09-07: the Android insets bridge counts its own dispatches `AndroidUiState::insets_report() -> String` is new, and the bench app's Diagnostics pane shows it. It carries the last insets plus **how many times the platform has delivered any**, because "the keyboard did not push anything up" has two causes that look identical on screen -- the listener never fired, or it fired with a zero height -- and you have no logcat on the phone. `dispatches=0` prints a sentence saying so rather than the numbers, which would be defaults rather than measurements. ## 2026-09-07: widgets can animate, and a fling finally moves Iris's phone said "fling still doesn't work" twice. The velocity was only half of it: **nothing in iris advanced an animation between input events**, so `List::fling` stored a speed that nothing ever applied. Three public changes come out of fixing that. **`Widget::tick(&mut self, now: Instant) -> bool`** is a new trait method, defaulted to `false`, so no existing widget changes. A widget that overrides it is animating; answering `false` is how it stops. **`UiData::animate(id)` and `UiData::tick_animations(now) -> bool`** are the registry and its driver. A gesture that starts an animation registers the widget; each backend calls `tick_animations` once per frame before the draw and asks for another frame while it answers `true`. That answer is the *only* thing in iris that makes a frame happen without an input event, and an animation's path out is its own `tick` returning false -- nothing has to remember to unregister it. // before: the velocity was stored and never applied list(ui).fling(-v); // after list(ui).fling(-v); let id = list.id(); ui.ui_mut().animate(id); The two calls are deliberate rather than folded into `fling`: the velocity is the list's business and whether anything animates at all is the frame loop's, and a caller driving its own frames (the benchmark, the headless tests) still calls `tick_fling` directly. **`FlingCalculator` needs the real display density, and its coefficient was wrong.** `new(density)` takes physical pixels per `dp` and the velocity handed to it must be in those same physical pixels -- the density does *not* cancel out, contrary to what that type's doc used to claim. Separately, `physical_coefficient` multiplied by the scroll friction (0.015) where AOSP multiplies by its own tuning constant 0.84, a factor of 56 inside an exponential. Together they gave an ordinary flick a **45-second** coast, which nobody could see while flings never animated. `List` reads its density from the painter now, and `a_flick_lasts_what_aosps_own_formula_says_it_does` pins the absolute numbers (0.59s and 621px for 3000px/s at density 2.75) against AOSP's formula -- the check every previous test could not make, because they all compared the calculator with itself. **`MOVE_CHAIN_LIMIT` is 64, not 16**, in `render_state.rs` and `shader.wgsl` alike. It bounds a walk so a cyclic `parent` cannot hang either side; it was never meant as a claim about tree depth, and the transcript screen's composer field sits 17 slots below the root. Past the bound both walks silently stop summing, so a widget draws and hit-tests short with nothing to say so; the CPU assert now prints the chain, so a cycle and a deep tree can be told apart. ## 2026-09-06: tool cards, `ToolState`, and a screen that knows whether its session is working `transcript_ui::tool` is new: a card per tool call, a group per run (P1b). Three things in the public surface follow from it. **`client_core::transcript_fold::ToolState`** is what a card colours itself by -- `Running`, `Deciding`, `Succeeded`, `Failed`, `NoResult` -- built by `ToolState::of(&item, session_working)`. The pair it exists for is `Succeeded` against `NoResult`: a call that finished having printed nothing and a call whose result never arrived both leave an empty `output`, and drawing them the same way states a verdict nobody reached. Only the session's own status separates them, which is why `of` takes it. **`event_model::Event::ToolEnd` gained `is_error`** (`#[serde(default)]`, so an older transcript still parses), and `client_core::transcript_fold::TranscriptItem::ToolRun` gained `failed`. Without them a result was everything a card knew and a broken call drew exactly as confidently as one that worked -- the missing state, not a wrong one. Every construction site of both had to gain a field; the value comes from the CLI's own `tool_result`, read in one place (`import::tool_result_is_error`) by both the live translator and the import replay. **`TranscriptScreen::set_session_working(rsc, bool)`** is new, and is the only thing that writes it. Before: a card with no result was drawn the same whether its turn was still going or had been interrupted. After: only the *newest* row can say "running", because every row behind it belongs to a turn that has ended, and changing the flag redraws that one row rather than the screen. `TranscriptScreen::expand_tail_tools(rsc, bool)` joins it, answering whether there was a tool run to act on -- a group's expanded appearance is otherwise unreachable from anything that cannot press the screen. **`transcript_ui::row::build_row` now returns a `TailRow`** rather than an `Option`: `Blocks` for a message (a delta costs the last markdown block) or `Tools` for a run (an arriving result costs one card). One mechanism for "what can this row change cheaply", asked of the row rather than decided again at each call site. It also takes the row's own `working` flag. Two smaller ones. `client_core::tool_summary::parse_tool_input` is `ToolInput.kt`'s subject/description/timeout/rest split, and `client_core::durations::format_millis` is `Durations.kt`'s -- both pure, both with the Kotlin's own tests ported. ## 2026-09-06: a tap is its own gesture outcome, and opening a URL is a backend capability Three related additions, all for following a markdown link. **`iris::platform::OpenUrl`** is a new trait beside `attr::FocusHost`, and has the same shape: declared in `iris`, implemented once per backend (a detached `xdg-open`/`open`/`start` on the desktop, an `ACTION_VIEW` intent on Android, deferred to the next view callback exactly the way `pending_show_keyboard` is). A widget asks for the capability by bound -- `Rsc::State: FocusHost + OpenUrl` -- instead of a caller threading a callback down through every builder. One method, not a general "run an intent": a narrower capability is a narrower thing to get wrong. Nothing is returned; the platform either shows a browser or does not, and both are outside the process. **`GestureOutcome::Tapped`** is new. `Released(None)` used to mean both "the press ended having selected something" and "the press ended having done nothing at all", and only the second is a tap. Any caller that acts on a tap -- following a link -- must not also act when the finger was panning the list past that link, so the distinction is made once, in the gesture machine every widget already shares, rather than timed again per widget. `DragArbiter::is_undecided()` is what answers it. `Selection::drag` returns the outcome now instead of `()`. **`DragArbiter`/`DragGesture` take an axis** (`::on(Axis)`; `::new()` is still vertical). A code fence pans across its own long lines exactly the way a transcript pans down its rows, and the two were the same state machine with `dx` and `dy` swapped. `WidgetLike::scrollable_on(axis)` joins `scrollable()` for the same reason. Before this, a horizontal `Scroll` existed but could not be dragged by a finger at all -- its arbiter only ever committed on the vertical axis. Two smaller ones in the same pass. **`TextEditCtx::byte_at(pos, size)`** answers which byte of the text a tap landed on, doing the same region-relative transform `select` does, without handing out the parley layout a caller could shape against stale text. And **`Rect::radius` now takes a `Len`**, so a corner can be written in `dp` and come out the same physical size on every display; a bare number still means physical pixels. **One behaviour change worth knowing about**: `Rect::is_size_independent()` answers `false` now. It answered `true`, and a `Rect` fills whatever region it is given -- so `draw_inner`'s fast path, which rewrites a widget's primitives in place instead of redrawing it, could not reproduce what `draw` would have done. A `.background(rect(..))` behind variable-height content kept the size of the provisional pass its parent `Span` had drawn it at, which on the transcript screen meant one code block's panel covering every block below it. Costs one primitive's redraw when a rect is resized. ## 2026-09-06: a transcript row is a column of blocks, and a block is the selection unit `transcript-ui`'s row builder used to make **one** `TextEdit` per message. It makes one per top-level markdown block now -- heading, paragraph, fenced code, list, table -- in a `Span::down`, because a streamed delta into a single buffer re-shaped the whole message through parley on every event. `client_core::markdown_blocks::split_blocks` does the splitting; `row::RowBlocks::apply_delta` updates the block a delta lands in and leaves the rest of the message's layout alone. **The change to judge, since it is what a reader feels**: `Selection` is keyed by `SelKey = (RowKey, u32)` -- a row and a block -- so **a block, not a row, is the unit a selection steps in**. A drag still runs from a reply into the tool output beneath it and copies as one thing; what changed is that the row under the finger is filled in block by block rather than all at once, which is if anything closer to what the old shortcut in `Selection`'s module doc was apologising for. `register` takes a `SelKey`; `unregister` still takes a `RowKey` and now drops every block of it (dropping only the first is how a freed widget gets left in the map -- the shape docs/REVIEW-2026-09-06.md's finding 1 called out). `Selection::locate(ui, render, pos_window)` is new: which block is under a window position, with that block's own local position and size. The list-level handler uses it for the pointer-captured half of a drag, instead of computing a row-local position from `List::extent`. `row::build_row` returns `(RowKey, StrongWidget, Option)` -- the third is the per-block state a caller keeps only for the row a reply is streaming into, and is `None` for a tool run, which never streams. ## 2026-09-06: a reported `Size` may not carry `dp`; `Len::fold_dp` **New: `Len::fold_dp(density) -> Len`** -- the same fold `apply_rest` does (`dp` becomes physical pixels), but staying a `Len` so `rest` survives. **New rule, and it is a rule about every widget, not about the two that broke it**: a `Len` a widget *reports* from `draw` must not carry an unresolved `dp`. `dp` is an input unit -- a number the widget author wrote -- and the containers that consume a reported length read `abs`, `rel` and `rest` straight off it (`Span`'s placement arithmetic, `Pad`'s addition), so a reported `dp` is silently worth **zero**. `MaxSize` and `Sized` both returned the caller's declared `Len` as written; a `.max_height(dp(168))` therefore gave its child a slot of nothing the moment the cap actually applied, which is what made the composer's bar collapse. Both put their declared lengths through `fold_dp` now, and `UiRenderState::draw_inner` `debug_assert!`s the invariant after every `Widget::draw`, so a widget that gets this wrong says so at the mistake rather than laying out at zero somewhere else. Nothing changes for a caller: `.max_height(dp(48))` is written the same way. It is only widget *authors* who now have a rule to follow, and a debug build that enforces it. ## 2026-09-06: `Painter::set_mask` reuses one slot; `ActiveData` gains two fields **`Painter::set_mask(region)` allocates its widget's mask slot once and rewrites it in place** on every later draw, instead of pushing a new one each time. It has to: `draw_inner`'s unchanged-region fast path does not revisit a descendant whose own region did not change, so those descendants go on referencing whichever slot they were first drawn under. Pushing a fresh slot per draw left the composer's field clipped to a box the bar had long since moved away from -- four live mask entries, none of them the `Masked`'s current region -- and it drew nothing at all. Same call, same signature; only the lifetime changed. **`ActiveData` gains `own_mask` and `move_applied`** (both public, since `ActiveData` is). `own_mask` is the slot above, `MaskIdx::NONE` for a widget that sets no mask. `move_applied` is how much of a widget's own move-slot delta its `region` already accounts for: `mov` shifts both, `Painter::reposition` shifts only the slot, and `resolved_region` -- and so every hit test -- has to subtract it. Without that a widget that had been panned had its *own* hit box at twice the pan while its descendants were correct, which made the composer's field untappable after a finger drag. ## 2026-09-06: `Scroll` pans on a finger drag, and a vertical drag in a focused text field no longer selects Three related public changes, all in aid of IRIS_TODO.md's "the composer has no touch-drag scroll". **`Scroll::drag(render, id, sense, pos_window, now)` is new**, and `WidgetLike::scrollable()` now registers it alongside the wheel handler it already registered -- so anything built with `.scrollable()` pans on a finger drag with no extra wiring at the call site. It goes through the same `sense::DragGesture` that `transcript-ui::Selection::drag` drives `List` with (arbitration, `DRAG_SLOP`, velocity, pointer capture), rather than a second copy of that widget's wiring: `DragGesture` owns the mechanics and each caller decides only what a committed pan *means*. `Scroll::amt()` is new too, the read-only pan position a test or a scroll indicator needs. There is deliberately **no fling** on `Scroll`. Unlike `List` it has no per-frame tick to animate one with (`List::set_redraw_handle`/`tick_fling`), and the areas it wraps today are at most a screenful, where Android does not fling either. The released velocity is dropped rather than approximated. **A vertical drag inside an already-focused `TextEdit` no longer extends a selection.** `iris::attr`'s `on_press` used to treat a focused field as the plain `click_or_drag` case -- every `Pressing` frame updated the selection. It now applies the same `DRAG_SLOP` rule the *unfocused* branch already applied: a press that moves past the slop vertically abandons its pending selection for the rest of the gesture, so the scroll area around the field gets the drag instead. Horizontal drag-to-select is unchanged, and a long press still starts a selection. This is Android's own `EditText` behaviour (a vertical drag scrolls; only a long press selects), and it is what makes "swipe up over the composer to scroll the transcript" work without dragging a highlight through the message you were typing. **`UiRenderState::orphaned_primitives()` is new**, and `update` now `debug_assert!`s (debug builds only) that nothing is orphaned. An orphan is a primitive still bound for the GPU that no live `ActiveData` names -- a copy nothing can move, clip or free. That was the doubled `Compacted:` row on the phone; see the same date's commit `76b1f99` and docs/RUST.md. The per-frame guard is a count comparison (O(active widgets)); the walk that names the offenders only runs when the counts disagree, because the walk is O(primitives) and made a debug build on a phone too slow to finish a benchmark run. ## 2026-09-06: a tap on a text field always leaves a caret `TextEditCtx::select` used to compare the tap position against the *laid-out text's* own box and set `selection = None` for anything outside it. A press only reaches `select` after being hit-tested to the widget, so that "outside" meant the field's own padding -- or, for an **empty** field, everything, since an empty layout is a zero-width box. So tapping an empty composer focused it and opened the keyboard while leaving no caret, and `TextEditCtx::insert`/`insert_str` return early with no caret: every keystroke was dropped in silence, and no glyph ever appeared. Parley's `from_point`/`extend_to_point` already clamp a point outside the layout to the nearest cursor position, which is also what a tap in a field's padding should do. Behaviour change a caller would notice, in one line: **`select` with a non-drag position now always produces a selection; it no longer clears one.** Clearing is `TextEditCtx::deselect`, which is what the backends' focus handling already calls. A drag is unchanged -- with no previous selection there is still nothing to extend, so it produces none. `insert_str` also gained a `debug_assert!` for the no-caret case, so an insert routed to an unfocused field fails at the mistake in a debug build instead of silently swallowing input. ## 2026-09-06: `List::anchor_position_display`## 2026-09-06: `List::anchor_position_display`, `FrameReport::mark_phase`/`phase_stats`/`late_at_hz` (RUST.md's "Benchmark v2") `List` gained `anchor_position_display(&self) -> String`, reporting the anchor's own row index and pixel offset (`idx=N/off=Mpx`, or `idx=more-before`/`idx=more-after`/`idx=none`) -- what a scripted benchmark reads to report fling travel. Note the anchor does not necessarily change *slot* over a long scroll (this widget's own documented design: the anchor is a stable identity, not re-derived from what's on screen each frame), so this is not the same measurement as a Compose `LazyListState.firstVisibleItemIndex`, which does track the true topmost visible row -- the `off` half is what actually reflects how far a fling travelled. `iris_core::render::frame_report::FrameReport` gained three methods for per-phase benchmark reporting: `mark_phase(name)` records a named phase boundary at the current frame/instant; `phase_stats(now, refresh_hz)` returns one `PhaseStats` (frames, wall duration, late count/percent, p50/p90/p99, worst) per marked phase, sliced from the existing ring by a new parallel `index_ring`; `late_at_hz(refresh_hz)` gives the whole run's late count/percent judged against an arbitrary refresh rate rather than the fixed 60Hz `JANK_THRESHOLD` every existing caller still uses (a separate method, not a parameter on `report()`, so nothing else changes behaviour). `RING_CAPACITY` grew 4096->16384 to hold a full multi-phase run without evicting earlier phases' samples. ## 2026-09-06: `List::fling`, `VelocityTracker`, `FlingCalculator` (IRIS_TODO.md's "swiping has no momentum") `iris::widget::List` gained a real fling: `fling(velocity_px_per_s)` starts one (cancelled by the next touch-down via `cancel_fling`, or automatically once it settles or reaches loaded content's start/end), `is_scrolling()` reports whether one is running, and `tick_fling(now: Instant) -> bool` advances it and returns whether it is still going -- a caller that owns a `RequestRedraw` handle can hand it to the list once via the new `set_redraw_handle`, after which `List` re-arms its own next frame while flinging with no further polling needed; a caller driving a scripted benchmark instead calls `tick_fling` itself in a loop, same as it already drives `scroll`. The physics is `iris::sense::FlingCalculator` + `VelocityTracker` (`sense.rs`, beside `DragArbiter`): a port of AOSP `SplineOverScroller`'s deceleration curve (the same one Compose's own `ScrollableDefaults. flingBehavior()` uses), cited at the definition, so a fling here travels the same distance a Compose `LazyColumn` would for the same initial velocity. `VelocityTracker` estimates that velocity from the drag's last ~100ms of samples rather than one frame's last delta. Unit-tested: velocity from known samples, fling distance/duration against the closed- form spline result (within 1%), cancel-on-touch, and the start/end clamp (a fling stops rather than scrolling into content that was never loaded). Before: a touch-drag panned exactly as far as the finger moved and stopped dead on release. After: releasing mid-drag continues scrolling and decelerates, matching the muscle memory every other Android scroll view already trained. `transcript_ui::selection::Selection::drag` wires this in -- a release only flings if the gesture had committed to panning (`DragArbiter::is_panning`, new), never a selection or an undecided tap. ## 2026-09-06: `UiRenderNode::new` returns `Result`, not `Self` (RUST.md's P0 box, phone-crash fix) `iris_core::UiRenderNode::new(device, queue, config)` now returns `Result` instead of `Self`. Why: it used to let a bind-group- layout validation failure reach wgpu's default error handler, which panics with no way for a caller to intervene -- exactly what aborted the P0 bench APK on Iris's phone with the crash report truncated to "wgpu error: Validation Error" and nothing else recoverable. It now runs its creation calls inside wgpu error scopes and returns the full error text (wgpu's own "Caused by" chain) as `Err` instead. Both callers changed to match: `android::render::AndroidRenderer::new` itself now returns `Result` too, building a fuller report (adapter identity, the limits/downlevel flags a layout validates against, then wgpu's text) on failure -- its caller, `android::view::IrisViewPeer::surface_changed`, logs that report as one logcat line and shows it on screen (a new `IrisView.showRendererError`, called via an ordinary JNI method call rather than a new `native fn`) instead of letting the process abort. `default::render::UiRenderer::new` (the winit/desktop backend) still panics on failure -- there is no on-screen fallback there -- but the panic message is now the same full text rather than whatever wgpu's own handler would have printed. No change for an app that never constructs a `UiRenderNode` directly (every current one goes through `AndroidRenderer`/`UiRenderer`), but anyone who does needs an `?`/`.expect()`/`match` at the call site now. Full audit and the named hypothesis for what actually failed on the phone are in RUST.md's P0 box, "iris bench crash on the phone, 2026-09-06." ## 2026-09-05: `AndroidAppState::platform_ready` (RUST.md's P0 box, iris half) Added a second, optional lifecycle method to `iris::android::AndroidAppState` (`iris/src/android/view.rs`), called once from `new_peer` right after `new`: ```rust fn platform_ready(&mut self, rsc: &mut AndroidRsc, vm: JavaVM, view: GlobalRef) {} ``` Default does nothing, so every existing implementor (`Client`, `TranscriptClient`) is unaffected. It exists for a caller that needs to call into Java itself beyond what a `RequestRedraw` handle already covers -- P0's bench build (`iris-android-app`'s new `bench` feature, `bench_client.rs`/`bench_jni.rs`) uses it to hold a `JavaVM` + `GlobalRef` to the view so its "Copy report" control and once-a-second battery sampler can call `BatteryManager`/`ClipboardManager` through the view's own `Context`, from a background tokio task as well as the UI thread. `new` itself was not extended with these two parameters: most implementors need nothing here, and `new`'s job is building the widget tree, not holding a platform handle. `vm`/`view` are independent handles from the ones `new_peer` keeps for its own `RequestRedraw` (a fresh `get_java_vm`/ `new_global_ref` each), so storing them has no effect on that mechanism. ## 2026-09-05 (later still): `iris_core::device_limits()`, and iris no longer requests compute-shader limits New public function, `iris_core::device_limits() -> wgpu::Limits`. Why: `adapter.request_device`'s `required_limits` was `Limits::default()` plus a `max_buffer_size` override in both platform backends, and `Limits::default()` requests desktop-tier compute-shader limits unconditionally (`max_compute_workgroups_per_dimension: 65535`) even though nothing in `iris`/`iris-core` uses a `ComputePipeline` — that crashed device creation outright on a downlevel GL adapter reporting OpenGL ES 3.0 (no compute shaders at all: the Android emulator's `EMU_GPU=software` path, and any real GLES-3.0-only Android device). `device_limits()` is what both `android::render::AndroidRenderer::new` and `default::render::UiRenderer::new` now build their `required_limits` from, so the request cannot drift between the two backends. Before: `Limits { max_buffer_size: 1 << 30, ..Default::default() }` inlined in each backend. After: `iris_core::device_limits()`, which is the same thing with the six `max_compute_*` fields additionally zeroed. A caller building its own `DeviceDescriptor` outside these two backends (there are none today, but a third platform backend would want this) should call `device_limits()` rather than reaching for `Limits::default()` directly, unless it genuinely adds a compute pass — in which case it wants the specific compute limits that pass needs, not the desktop-tier default for everything. ## 2026-09-05 (later the same day): `iris_core::FrameReport` (RUST.md's I5 box) New public type, `iris_core::FrameReport` (re-exported from `iris_core`'s `render` module alongside `FrameStats` and `JANK_THRESHOLD`). Why: `dumpsys gfxinfo` cannot see a `SurfaceView`'s own GPU-drawn frames at all, so a `wgpu`-rendered iris screen had no way to ask "was this smooth" the way Compose's own in-app render report already can -- item 3 of RUST.md's recommendation was stuck on a one-sided number for exactly this reason. `FrameReport::record(elapsed: Duration)` is called once per frame (wired into `android/view.rs`'s `render()`, wrapping the same span from redraw start to after `queue.submit`+`present()` that Compose's report and `gfxinfo` both count) and writes into a fixed 4096-entry ring -- no allocation on the hot path. `FrameReport::report() -> Option` gives total frames, janky % (over `JANK_THRESHOLD`, the same 16.7ms 60Hz budget `gfxinfo` uses), P50/P90/P99 and the worst; `None` if nothing has been recorded since the last `reset()`, not a zeroed report that would read as a real measurement. `FrameStats`'s `Display` line says plainly that it measures up to `present()` being called, not GPU/compositor completion, since wgpu's `present()` isn't fenced against either. `AndroidUiState` gained a `pub frame_report: FrameReport` field -- anything with `HasAndroidUiState` can now read or reset it. Before this, there was no way to ask iris's own render path how long a frame took at all, on any backend. Before/after, for a caller that already has `ui_state: &AndroidUiState`: ```rust // before: no such question could be asked // after: match ui_state.frame_report.report() { Some(stats) => log::info!("iris frame report: {stats}"), None => log::info!("iris frame report: no frames recorded yet"), } ui_state.frame_report.reset(); // via android_state_mut() ``` `iris-android-app`'s transcript screen exposes this as two named, tappable controls ("Frame report", "Reset frame report") rather than requiring a caller to wire its own UI -- see `transcript_client.rs`'s `frame_report_controls`. ## 2026-09-05: `Tasks::redraw_handle` (RUST.md's I5 Android integration) New public method on `iris::task::Tasks`, `redraw_handle(&self) -> Arc`. Why: a caller running its own long-lived loop *inside* one spawned task (a live SSE follow, the Android transcript client's `select_session`) has no other way to ask for a frame after each `TaskCtx::update` -- `Tasks::spawn`'s own wrapper only requests one, after the whole async closure finishes, which fits a single request-then-update but not a stream that needs to be seen redrawing after *each* event. This is the same gap `iris/desktop-app`'s module doc names for why it uses winit's `Proxy` instead of `Tasks` -- android-view has no `Proxy`, so this is what closes it there. **A real bug this uncovered, not a hypothetical**: calling the returned handle's `request_redraw()` from the background thread crashed the process (`SIGABRT`, `Result::unwrap() on an Err value: JavaException`) the first time an Android transcript fetch called it a second time. `android/render.rs`'s `AndroidRedrawHandle` was already attaching the calling thread to the JVM correctly, but its `request_redraw` called `View::post_frame_callback`, whose Java side calls `Choreographer.getInstance()` -- which throws unless the *calling* thread already has a `Looper`, and a tokio worker thread, even freshly JNI-attached, has none. Fixed by routing through `View::post_delayed(0)` instead (Android's own thread-safe "queue work onto this View's UI thread" primitive, needing no caller-side `Looper`), landing on a new `IrisViewPeer::delayed_callback` override that drains tasks and renders -- same body as `do_frame`, on the UI thread where `post_frame_callback` is safe again. Any future caller of `redraw_handle()` from a background thread gets this for free; nothing about the fix is specific to the transcript screen. ## 2026-09-05: `transcript_ui::build_tree` (RUST.md's E4) `transcript_ui::build` claimed the whole window (`ui_state.set_root(tree)`) as its last step, which is right for a window that *is* the transcript screen (the winit example, an eventual Android cdylib) and wrong for the desktop app, which puts a session list beside it. `build_tree` is `build` minus that last step: it returns `(TranscriptScreen, StrongWidget)` instead of just `TranscriptScreen`, and the caller decides where the tree goes — into `ui_state.set_root`, or into a `WidgetPtr` alongside something else (`iris/desktop-app`'s `rebuild_transcript`). `build` is now one line calling `build_tree` and doing the `set_root` itself, so existing callers are unaffected. ```rust // before, and still available, for a caller that wants to *be* the window: let screen = transcript_ui::build(rsc, &mut ui_state, rows); // new, for a caller embedding the screen beside something else: let (screen, tree) = transcript_ui::build_tree(rsc, rows); some_widget_ptr(rsc).set(tree); ``` ## 2026-09-05: `DragArbiter`, pan-vs-select for one shared touch gesture (RUST.md's I5) New public type, `iris::sense::DragArbiter`. Why: a widget author who registers both a list-level pan and a row-level drag-to-select on the same touch gesture has no way to arbitrate between them — `core/src/sense.rs`'s `run_sensors` always gives the innermost layer first refusal, so the inner one wins every frame it is pressed, not just the frame the press started (this is exactly what left transcript-ui's touch-drag panning unreachable until now). `DragArbiter` is one small state machine, one instance per gesture surface (a whole list, not per row), that a caller drives with its own `press_start`/`update`/`release` calls and a caller-supplied `Instant` (so it is unit-testable without a real clock or a render harness). It decides the way Android itself does: an ordinary vertical drag pans immediately; a stationary press held `LONG_PRESS` (500ms) starts a selection, which any further drag then extends; a horizontal drag while something is already selected extends it immediately, skipping the wait. ```rust // One per list, held alongside whatever state coordinates the rows: let mut arbiter = DragArbiter::new(); // On press-down: arbiter.press_start(pos, Instant::now(), already_selected); // Every frame the button/finger stays down: match arbiter.update(pos, Instant::now()) { DragOutcome::Pan(dy) => list.scroll(-dy), DragOutcome::SelectStart => selection.begin(...), DragOutcome::SelectExtend => selection.extend(...), DragOutcome::Undecided => {} } // On release: arbiter.release(); ``` `transcript-ui`'s `Selection::drag` (`transcript-ui/src/selection.rs`) is the reference caller: every row's `CursorSense::click_or_drag() | CursorSense::unclick()` handler routes through one `Selection`-owned arbiter instead of calling `begin`/`extend` directly, so a drag that starts on a row's own rendered text now pans the list correctly instead of always starting a selection. 8 new unit tests in `iris/src/sense.rs`'s `drag_arbiter_tests` module. ### 2026-09-05, later: `DragArbiter::is_idle()`, recovering a missed `press_start` Follow-up to the above, from a real touch-scroll dropout: a gesture's `ACTION_DOWN` can land on a caller's own dead space (a row's padding, a gap, a header with no handler) that never calls `press_start`, so the first frame the arbiter actually sees is a `Pressing`-shaped `update` with no matching start. Before this, `update`'s `Idle` arm had no way to tell that apart from "nothing is happening" and answered `Undecided` forever for the rest of that gesture. `is_idle(&self) -> bool` lets a caller notice the gap and recover: if `is_idle()` is true on a frame the caller knows a press is genuinely down (its own `Pressing`/equivalent sense fired), call `press_start` right there instead of assuming one already happened. `transcript-ui`'s `Selection::drag` is the reference caller — one new match arm, checked before the ordinary `update`-only case. Any other `DragArbiter` caller with the same "one sensor per sub-region, no fallback for dead space" shape has the same gap and wants the same recovery. ## 2026-09-05: `SpanStyle`, per-range text styling (RUST.md's I5) A `TextBuffer` used to have exactly one style (`TextAttrs`: colour, size, family, ...) for its whole string, applied via `push_default` into parley's ranged builder. `SpanStyle` is a second, optional layer: a byte range plus whichever of colour/family/font size/bold/italic/underline it overrides, pushed with parley's own `push(property, range)` instead. Why: a transcript row's markdown (a heading, **bold**, `inline code`, a link) all inside one wrapped paragraph needs each to carry its own look while the paragraph still wraps and selects as a single buffer — the thing `masonry`'s `TextArea` cannot do (`StyleSet` is one style for the whole editor, `text_area.rs:43-44`'s `// TODO: RichTextInput`), and the reason this existed at all. ```rust let (text, spans) = transcript_ui::markdown::render_markdown(src, 16.0); wtext(text) .spans(spans) // new: TextBuilder::spans, on both Text and TextEdit .editable(EditMode::MultiLine) .add(rsc); ``` Two things a widget author should know before reaching for it: - **Call `.spans()` before or after `.editable()`, both work** — the field lives on `TextBuilder` itself, not either output type, and both `TextOutput::run` and `TextEditOutput::run` apply it to the buffer via `TextBuffer::set_spans`. **These two call sites are a pair**: adding a third `TextBuilderOutput` impl without also calling `set_spans` there reproduces the exact bug this box shipped once already (spans silently dropped for `TextEdit`, found only by screenshotting, not by any test — `markdown.rs`'s own unit tests check string/range logic, which is correct in isolation and proves nothing about whether the render path ever sees it). - **Colour is now per-glyph, not per-buffer.** `PlacedGlyph` gained a `color: UiColor` field (from parley's own per-run `Style::brush`), and `Painter::glyphs` draws each glyph in its own colour instead of `RenderedText::color` uniformly. `RenderedText::color` still exists (the buffer's *base* colour, for a caller that wants it as a whole, e.g. to tint a cursor) but no longer drives what a glyph actually renders as. ## 2026-09-05: accessibility names via AccessKit (RUST.md's I4) `.label()` (already in `trait_fns.rs`, previously unused anywhere in-tree) is now load-bearing: it's the one thing that puts a widget in the AccessKit tree `iris_core::ui::access::AccessTree` builds and both backends push out. A widget author who wants a control to be findable by name (and tappable by name, through `ui-trace`/a real screen reader) calls `.label()` on it; nothing else is required, and a widget nobody labels is invisible to this system at zero cost, not just zero UI. ```rust let button = rect(Color::LIME) .on(CursorSense::click(), move |_, rsc| { ... }) .label("Add task"); // now findable by uiautomator/AccessKit as "Add task" ``` Two new things a widget author might touch directly: - **`Widget::access_role(&self) -> accesskit::Role`**, default `Unknown`. Override it if your widget has a real platform equivalent — `TextEdit` now returns `TextInput`/`MultilineTextInput` by `EditMode`. Only consulted for a widget that also has a `.label()`; an unlabelled widget's `access_role` is never called. - **`Widgets::named() -> impl Iterator`** — every widget with an explicit label, for anything else that wants to walk the same set `AccessTree` does. Nothing about `Painter`, `draw`, or the layout/move machinery changed — this sits entirely beside them, reading `resolved_region`'s output rather than participating in producing it. ## 2026-09-05: `List`, a virtualised bottom-anchored list (RUST.md's I3) A new widget, `iris::widget::List` (`iris/src/widget/list.rs` -- read its module doc first), for the transcript's kind of screen: variable-height rows, keyed by a `u64`, composed only while visible, moved rather than re-laid-out on scroll, a scroll anchor that survives a row inserted above it, "more" sentinels at each end, and "hold the edge nearest the tap" when a row's height changes (`note_tap`, resolved in the layout pass). ```rust let mut list = List::new(Axis::Y); list.push_back(ListRow::new(key, row_widget)); // O(1) list.push_front(ListRow::new(older_key, row)); // O(1), anchor unaffected list.set_more_before(Some(spinner_widget)); // sentinel, drawn at the edge list.note_tap(viewport_y); // before mutating a row's height let (top, bottom) = list.extent(key).unwrap(); // last frame's on-screen box, if visible ``` Built entirely out of existing primitives (`Painter::widget`/`widget_within`/ `reposition`/`draw_twice`, and `draw_inner`'s own old-children diffing) -- no new mechanism was added to the render core for it. One correctness lesson worth reading even for other widgets: a row that fills whatever region it is offered (`Rect`, `is_size_independent`) cannot be measured at a throwaway oversized region and then merely `reposition`ed into place -- `reposition` only ever writes an offset, never a size, so the oversized primitive stays oversized. `List` fixes this by caching each row's real height once measured and placing an already-known row directly at its exact box; see `list.rs`'s `place` for the full reasoning and `a_fill_shaped_background_is_not_left_oversized` for the regression test. ## 2026-09-05: a second backend (android-view), and what moved to make room for it RUST.md's I2. Three changes a widget or app author would notice, all in service of the same thing: `default` (winit) and the new `android` (android-view) backends sharing what does not depend on windowing. - **`Selector`/`Selectable`'s bound changed from `Rsc::State: HasDefaultUiState` to `Rsc::State: FocusHost`** (new trait, `attr.rs`). `HasDefaultUiState` still exists and still works — `default/attr.rs` now implements `FocusHost` for anything that has it — so a winit app's existing code is unaffected. An Android app implements `FocusHost` via `HasAndroidUiState` instead. Affects only an app that referenced `HasDefaultUiState` directly at a `Selectable`/`Selector` call site rather than through `.attr::(())`, which nothing in-tree does. - **`Tasks::init` takes `Arc` instead of `Arc`.** `RequestRedraw` (`task.rs`) is one method, `fn request_redraw(&self)`; `winit::window::Window` implements it (`default/render.rs`), so `Tasks::init(window)` at a call site is unchanged by inference. Only matters if something constructed a `Tasks` directly rather than through `DefaultRsc`/`AndroidRsc`. - **`TextEdit::apply_event`/`TextInputResult` are `#[cfg(not(target_os = "android"))]`** — they take a `winit::event::KeyEvent`, which does not exist on Android; `android/input.rs` drives the same primitives (`backspace`/`delete`/`motion`/`insert`, all still unconditional) from `ndk::event::Keycode` directly instead. New unconditional getters on the way: `TextEdit::text()`/`selection_range()`/`caret()`, and `TextEditCtx::delete_byte_range`/`set_cursor_byte` — the primitives `android/ime.rs`'s `InputConnection` bridge needed and that were not previously exposed publicly. ## 2026-09-04: `Widget::draw` reports the size it used; `desired_width`/`desired_height` are gone A widget used to implement three methods (`draw`, `desired_width`, `desired_height`); it now implements one, `fn draw(&mut self, painter: &mut Painter) -> Size`, which draws into `painter.region()` and returns how much of it was used. Why: the two extra methods routinely re-simulated what `draw` was about to do anyway (`Span::desired_ortho` copied its own draw loop to get cross-axis sizing right) — one visit per widget per frame instead of up to three. A container that needs a child's size before placing it (alignment, centering) draws the child once at a provisional region, reads the returned `Size`, and calls the new `Painter::reposition` to move it into its final spot — an O(1) offset write, not a second draw. A widget whose drawn output never depends on the size it's given (a fixed-size `Rect`, a decoded `Image`) overrides the new `fn is_size_independent(&self) -> bool { false }` to `true`, which skips redrawing it when only its offered region changes shape. ```rust // before fn draw(&mut self, painter: &mut Painter) { /* ... */ } fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ } fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ } // after fn draw(&mut self, painter: &mut Painter) -> Size { /* ... */ } ``` `SizeCtx` and `Cache` are gone with it — see `LAYOUT.md` for the full design, the move-offset mechanism this shipped alongside, and the file list. ## 2026-09-04: texture pipeline rebuilt off the binding array `Textures`/`TextureHandle`, `GlyphPrimitive`, and `UiRenderNode::new` all changed shape. Why: the old pipeline bound every texture ever drawn in one `binding_array>` and asked every device, unconditionally, for `VK_EXT_descriptor_indexing` — a real share of Android GPUs lack it, and it failed outright on the Android emulator's software Vulkan. See TEXTURES.md's "Recommended shape" and "Implemented, 2026-09-04". - **`UiRenderNode::new` drops its `limits: UiLimits` parameter, and `UiLimits` is gone.** Before: `UiRenderNode::new(&device, &queue, &config, UiLimits::default())`. After: `UiRenderNode::new(&device, &queue, &config)`. Nothing replaces it — there are no more binding-array limits to size. - **`src/default/render.rs`'s device request asks for no features and no binding-array limits.** Before: `required_features: Features::TEXTURE_BINDING_ARRAY | Features::PARTIALLY_BOUND_BINDING_ARRAY | Features::SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING` plus two `max_binding_array_*` limits. After: `Features::empty()` (the `DeviceDescriptor` default) and only `max_buffer_size` set, which was never about the binding array. - **`TextureHandle` has no `primitive()` method any more**; a caller outside `iris` shouldn't have been calling it (it fed the old renderer's internals), but if something did: use `image_index()` for a standalone image's bind-group index. There is no equivalent for a page — a page has no bind group of its own now, see below. - **`GlyphPrimitive` has no public constructor from a struct literal.** Before: `GlyphPrimitive { uv_min, uv_max, view_idx, sampler_idx, color, flags }`. After: `GlyphPrimitive::new(uv_min, uv_max, layer, color, flags)` — one `layer` (the shared atlas array's layer) instead of a `view_idx`/`sampler_idx` pair, since a page is now a layer of one array texture rather than its own bound texture. - **A widget author drawing images is unaffected**: `Painter::texture`/ `texture_at`/`texture_within` and `Textures::add` keep their signatures. What changed underneath is that each standalone image now gets its own `wgpu::BindGroup` and draw call instead of a slot in the shared array — invisible from the widget API, visible only in `UiRenderNode`'s internals and in `iris`'s device requirements. ## 2026-09-05: `FrameReport` splits each frame at `queue.submit` `FrameStats` gains two fields, and `FrameReport` gains a second recording method, to answer "is a slow frame iris's own CPU work or the driver/GPU" with a number instead of a guess (RUST.md's I5 box). - **`FrameReport::record_split(total, submit_to_present)`** is a second way to record a frame, alongside the existing `record(total)` (unchanged, and still what a caller with no split should use — it now reads as `cpu_p50 == total`, `gpu_wait_p50 == 0`, rather than fabricating a number for a half it never measured). - **`FrameStats` gains `cpu_p50` and `gpu_wait_p50`**: medians of redraw-start-to-submit and submit-to-after-`present()` respectively, independent of each other and of the existing `p50`/`p90`/`p99`/`worst` (which are unchanged, and still over the whole frame). The Android renderer's `draw()` now returns the `submit_to_present` `Duration` it measured, which `android::view::render()` passes to `record_split`. - **Caveat carried in both doc comments**: `submit_to_present` is not fenced against the GPU actually finishing — it is "how long the CPU was blocked handing the frame to the driver," not a confirmed GPU-completion time. Enough to separate "iris is slow building the frame" from "iris is slow handing it off," not enough to claim an exact GPU budget. ## 2026-09-05: `List::replace_back`/`List::clear`, and `TranscriptScreen::apply` Fixes the "every client refolds and rebuilds the whole widget tree per streamed event" cost RUST.md's P0 box measured (20 events/second against a ~3,200-row transcript). Two small additions to `iris::widget::List` (`iris/src/widget/list.rs`), plus one new method on `transcript-ui`'s `TranscriptScreen`. - **`List::replace_back(row: ListRow) -> Option`**: swaps the *last* row's widget for a new one without moving it — same slot index, so an anchor already pinned there (in particular a list flush with its own end) stays pinned, and a `List` scrolled elsewhere is untouched. `None` if the list is empty. `RowKey` may differ between the old and new row; only `heights`/`extents` care, and both are invalidated for the evicted key the same way `pop_back` already does. - **`List::clear()`**: drops every loaded row and resets to `List::new`'s state (`more_before`/`more_after` untouched — a caller that wants those cleared too calls `set_more_before(None)`/`set_more_after(None)` itself). The fallback path for a change that touches more than the tail. - **`transcript_ui::TranscriptScreen::apply(&self, rsc, old: &[TranscriptItem], new: &[TranscriptItem])`**: the incremental alternative to rebuilding the whole screen from `transcript_ui::build_tree` on every folded event. Diffs the two `group_tool_runs` outputs and picks the cheapest update: nothing changed (no-op), a pure append (`push_row`, unchanged cost), or — the common streaming case, a delta into a still-open assistant message — a rebuild of just the one changed row via `List::replace_back`, with any further new rows appended after it. A row changing *before* the tail (only `group_tool_runs` retroactively grouping tool calls into a run does this) falls back to `List::clear` plus a full rebuild, counted in `TranscriptScreen::take_rebuilds()`. **A caller that keeps its own row-keyed side table alongside `List` (`Selection`'s `rows: BTreeMap>` is the one this crate has) must clear it in step with `List::clear()`** — the fallback drops every row `List` was holding, so any side table not cleared the same way is left pointing at widgets the clear just freed (docs/REVIEW-2026-09-06.md finding 1, fixed 2026-09-06 by `Selection::clear()`, called from `apply`'s `Rebuild` arm right before `List::clear()`). `bench_client.rs`, `transcript_client.rs` and `desktop-app/app.rs` all call this now instead of rebuilding on every event; only the opening page (and `apply`'s own fallback) still calls `build_tree`. - **`TextEditCtx::set_with_spans(text, spans)`**: `set()` plus a fresh `Vec` in one call, needed because a streamed row's markdown re-renders to both a new string and a new span list on every delta and the two have to land together — a stale span list drawn against new text can point past its end. `set()` itself is unchanged (still clears spans to none, as before). Measured on this checkout's emulator (`iris/android-app/run-bench.sh`, release, x86_64, `force-gles`): worst-frame and p99 during the streaming phase dropped from 369.3ms/284.5ms (full rebuild per event, prior pass) to ~101–130ms/~76–103ms across three runs (this fix) — see RUST.md's P0 box for the full numbers and the comparison's caveats (different AVD instances, not a controlled A/B on identical hardware state). ## 2026-09-06: bundled fonts, `content_scale`, `AndroidAppState::on_insets_changed` From RUST.md's P0 box, working Iris's first real-phone report (font/scale/ inset bugs the emulator never showed). - **`TextData` now bundles Noto Sans + Noto Sans Mono** (regular/bold/ italic/bold-italic static faces, OFL) and registers them ahead of the platform's own fonts in the `SansSerif`/`Monospace` generic-family lists, rather than relying on the platform's font enumeration alone. `TextData::font_diagnostics() -> FontDiagnostics` reports what was found and what each style axis resolved to — logged once at startup and shown on a screen's Diagnostics page if it has one. Adds ~3.6 MB uncompressed to any binary linking `iris-core`; `build-apk.sh`'s own output says the delivered (compressed) number. - **`UiRenderNode::new`/`resize` now take the window size explicitly** (`window_size: impl Into`) instead of deriving it from the surface's physical `SurfaceConfiguration`. Existing callers pass a *logical* size (physical ÷ density/scale-factor) now; this is what makes a `font_size: 16.0` 16 dp instead of 16 raw device pixels on a high-density phone. Before this, `scale_factor` did not exist anywhere in the crate, on either platform. - **`AndroidUiState::content_scale: f32`** (`DisplayMetrics.density`, read once in `new_peer`) and the desktop equivalent (`window.scale_factor()`) now divide every physical-pixel number before it reaches layout or touch handling — see `content_scale`'s own field doc for the full list of what depends on it. - **New: `AndroidAppState::on_insets_changed(&mut self, rsc, LogicalInsets)`**, a default-no-op hook called from `render()` exactly when `AndroidUiState::insets()` changes. Nothing previously consumed `insets().top` at all; a screen with chrome under the status bar implements this to pad it, in the same logical units `content_scale` converts everything else to. - **New: `iris_core::WgpuErrorLog`**, installed via `Device:: on_uncaptured_error` on the Android device (wgpu's default handler is an unconditional panic outside `UiRenderNode::new`'s own error scopes). Explicit `Arc`-backed value passed to the callback and kept on `AndroidRenderer`, not a global — a caller wanting one on desktop builds its own the same way. ## 2026-09-06: `Len::dp`, physical pixels throughout, the keyboard glyph wipe Iris's phone report on build a9232ac (screenshots): text now the right size but blurry; the keyboard still wipes every glyph; the header buttons have nothing behind them. All three are fixed; this entry is the public API side. docs/LAYOUT.md has the layout-side writeup, docs/RUST.md's P0 box has the full investigation and the phone verification still to do. - **The keyboard wipe was `surface_changed` rebuilding the whole renderer on every resize**, including an IME-driven one — a fresh, empty glyph atlas while the CPU-side glyph cache kept UV coordinates from the old one. `surface_changed` now calls `AndroidRenderer::resize` (reconfigures the surface and window uniform only) when a renderer is already live, and only builds a new one when there genuinely isn't one yet. - **`Len` has a third field, `dp`** (Android's dp / CSS's reference pixel, 1/160in), beside the existing `abs` (now explicitly *physical* pixels) and `rel`/`rest`. `len_fns::dp`/`Len::dp` construct one, used exactly like `abs`/`rel`/`rest` — `dp(16)` instead of a bare `16` wherever a size should look the same physical size on any density. This is the unit IRIS_TODO.md's "density-independent length unit" item asked for; it replaces the previous stopgap (the whole rendered scene divided by `content_scale` then implicitly stretched back up), which is also what made text blurry — a glyph rasterised at the small, pre-stretch size and then upscaled onto the real framebuffer. - **`UiRenderState`/`Painter` gained `density()`/`set_density()`** (physical pixels per dp). Every place a length resolves (`Len::apply_rest`, `Size::to_uivec2`) now takes it; `Span::gap` and `Padding`'s four sides moved from a bare `f32` to `Len` so they take `dp(...)` too. A bare number anywhere is unaffected — still `abs`, physical pixels. - **Text is rasterised at physical resolution now.** `TextBuffer::shape` takes `density` and multiplies `font_size`/`line_height` (and any span override) by it before handing them to parley, so the atlas holds a bitmap at the size it is actually shown at rather than a low-resolution one stretched afterward. - **Everything at the Android boundary is physical pixels now** — window size, touch coordinates, insets (`LogicalInsets` renamed `WindowInsets`). The previous "logical" division by `content_scale` is gone; `content_scale` now feeds `set_density` instead. - Not yet verified on Iris's actual phone (this pass had no device) — built and checked on this checkout's emulator only. RUST.md's P0 box says what she should check for: crisp text at two densities, the keyboard no longer wiping, and the header's background. ## 2026-09-06: composing text, focus-on-tap, and atlas invalidation on a new renderer Three small but public API changes, from the same phone-report pass as the entry above (RUST.md's P0 box has the full account, including a real bug still not root-caused). - **`FocusHost` gained `is_focused(&self, id) -> bool`** (both platform impls). `attr.rs`'s `Selector`/`Selectable` used to grant focus (and so request the IME) on the very first frame of *any* press, before it was known whether the gesture was a tap or a drag — a swipe over a text field wrongly summoned the keyboard. They now wait for a completed tap (press and release with no frame crossing `sense::DRAG_SLOP`) unless the field is already focused, in which case dragging inside it to select text is unchanged. `TextEdit` gained one new `pub(crate)` field (`press_origin`) to track this; no public surface change there. - **`android::ime`'s `InputConnection` now calls `InputMethodManager:: updateSelection` after every edit** (`IrisViewPeer::update_ime_selection`, called from `after_input`). Gboard was holding keystrokes back because nothing ever told it where the app's own selection/composing region had moved to — this is what android-view's own demo does in its `render()` and this bridge never did. - **`GlyphAtlas::clear()` and `Textures::reset()`** (`iris_core`). Called together, once, from `android::view`'s `surface_changed` exactly when a *genuinely new* `AndroidRenderer` is built (backgrounding and returning, not a keyboard-triggered resize, which already reuses the renderer) — both CPU-side caches otherwise kept pointing at the old, now-destroyed device's textures, which is why text used to vanish again after leaving and returning to the app. ## 2026-09-06: `take_counters` counts text layouts too One public API change, from the verification pass over the composer-scroll and per-block-row work (RUST.md's "Verification pass over Tasks A and B"). - **`UiRenderState::take_counters` returns four numbers, not three**: `(draws, region rewrites, move writes, **text shapes**)`. The new one is bumped in `Painter::render_text`, which `TextView::render` only reaches on a cache miss, so it counts layouts actually computed rather than layouts asked for. Callers destructuring the tuple need one more `_`. It exists because a draw counter cannot answer the question the per-block transcript row was built for. A widget can be redrawn without re-shaping (the layout is memoized by width) and re-shaped without any extra draw, and re-shaping is the expensive half — so "a streamed delta costs one block" was, until now, argued from the code rather than measured. With the counter it is a test: one delta into a 100-paragraph reply shapes exactly **1** text layout, the same as into a one-paragraph one. ## 2026-09-07: `iris::diagnostics` -- a trace toggle for input/frame lines, gating four existing per-frame `debug!` calls One new public module and one behaviour change to four existing log lines, from Iris's "add another button to copy input event info ... instrument a lot of the code with timings" request (RUST.md's own section has the full account). - **`iris::diagnostics::set_trace(bool)`/`trace_enabled() -> bool`**, a process-global switch, off by default. It gates two new diagnostics (`sense::log_input_event`, one line per platform pointer sample under target `iris::input`; `diagnostics::log_frame`, one line per frame under `iris::frame`, with the frame number, the frame clock, time since the last input, layout/draw durations, `RedrawKind`, primitives on screen, and whether something is animating) and, as of a same-day review finding (D1), four *older* `debug!` lines that were previously unconditional: `android::view`'s two `render():` lines, `widget:: list`'s `iris fling tick:`, `widget::text`'s `iris text render:`, and `sense`'s `iris drag release samples:`. Not `log::log_enabled!`/ `log::set_max_level`, because the app installs its logger at `LevelFilter::Debug` already and the ring records everything that level lets through regardless of target — the gate has to live on this side. **Not wired to a control**: the Diagnostics pane is in `bench_client.rs`, off-limits while another agent had it open; this is the whole surface a button needs. - **`UiRenderState` gained `RedrawKind`, `frame_number()`, `epoch()`, `last_layout_duration()`, `last_redraw_kind()`, `active_primitive_count()`, `note_input(Instant)` and `time_since_input(Instant) -> Option`** (`iris-core`). All read back by `log_frame`; `note_input` is called once from `SensorUi::run_sensors`, which both backends and the harness already share, so a frame's `since_input` is comparable across all three without either platform doing its own bookkeeping. - **`iris::harness::TouchAction` gained `word() -> &'static str`**, the inverse of its own `parse` -- what a caller (here, `Harness::touch`) hands the input logger so a `.touch` file and an `iris::input` line agree on one spelling of each action. - **`iris_core::Axis` gained `Debug`** — a one-line derive, needed to log which axis a drag committed to. - **`iris/benches/report_to_touch.py`** (new): turns a report's `iris::input` lines back into a `.touch` file, expanding inline historical samples into their own lines first. Round-tripped against the harness in `iris/transcript-fixture/tests/input_log_roundtrip.rs`. ## 2026-09-07: the phone app is told which server to talk to, and pins from the link Not an iris API change -- a client-facing one, in the crates around it, worth knowing because it changes what a build of the Android app *is*. - **An iris APK is no longer tied to the machine that compiled it.** It used to have the server's host, port, token and CA compiled in, which made a build good for exactly one emulator/server pair and put a token in the artifact. Now it registers `aiapp://enroll` like the Compose app: open the link (Dev Updater's Enroll button already offers it, and the phone asks which app should take it) and the app stores where to go and what to trust. - **The CA rides in the link** as `&ca=`, which is what makes the above possible at all -- a pinned certificate cannot be baked into an APK cross-compiled somewhere else. Optional, so the projects that do build on their own machine keep the short link and the small QR. docs/DECISIONS.md, 2026-09-07, has why not a fingerprint. - **`client_core::config` now holds the storage as well as the parsing**: `EnrolledServer` gained an optional `ca_pem`, and `EnrollmentStore` (the 0600 JSON file, moved out of `desktop-app`) is one implementation for both the desktop and the phone -- only the directory differs. `desktop-app --ca` is now the override for a link that carried no CA rather than a required flag. ## 2026-09-08: a new GPU device re-uploads its textures instead of forgetting them, and a mark is one texture per shape Two defects with one cause: **`widget::mark` built a texture per widget**, so a transcript screen had one 48x48 standalone image, one bind group and one draw call *per folded card* rather than one per picture -- and the Android surface-rebuild path assumed no long-lived widget held a texture handle at all. - **`Textures::reset` is gone; `Textures::reupload` replaces it.** A new GPU device holds none of the old one's textures, but this side still holds their pixels, so the answer is to queue every slot for upload again in slot order (empty slots included, as `PushFree`, so the indices after a hole still land where they were) rather than to throw the slot numbering away. Resetting left every live `TextureHandle` naming a slot nothing recognised: the first frame after the emulator's Vulkan-to-GLES fallback panicked with *"texture slot 89 is not a live standalone image: None"*, before anything had been touched. - **The glyph atlas is no longer cleared on that path either**, which falls out of the same change: its pages are slots here and their pixels are on this side, so re-uploading restores exactly the atlas that was there. An app switch no longer re-rasterises every glyph on screen. - **`Textures::shared(key, make)`** (new): the one texture for a description, built on the first ask and handed out again after, keyed by a `SharedTextureKey { owner, id }` the caller packs *exactly* rather than hashes. The map holds its own reference, so a shared slot is never freed and never recycled under a widget still drawing it. `mark()` is its first caller: three marks now exist for the whole transcript screen (open, closed, collapse) instead of one per card, and the rasterising is paid once. ## 2026-09-08: an app's own log survives the process that wrote it `devlog`'s provider could only ever show the run that was still up. After a crash, Dev Updater's query starts the app process **for the provider alone** -- no activity runs, so `MainActivity.nativeSetFilesDir` never fired and the panic hook's file was never replayed. The Runtime tab therefore showed one line, `iris devlog: serving this app's log at ...`, which is exactly the run nobody needs. - **`DevLogProvider.nativeReady` now takes the files directory too**, and `app_log::set_crash_dir` is called from whichever of the provider and the activity runs first (it deletes the file, so the second says nothing). - **The panic hook saves context, not just the panic**: the dying run's last 80 log lines go into the file with it, and are replayed into the new run's ring ahead of the panic line, so the Runtime tab reads chronologically -- what the app was doing, then what killed it, then this run. They are read with a new non-blocking `LogRing::try_tail_text`, because a panic raised while the ring's own lock was held would otherwise deadlock the hook and hang the process instead of aborting it. ## 2026-09-08: iris ships an icon font, and `widget::mark` is gone Iris's question -- "why does mark exist? The font should be working if it's working for compose and nerd fonts are bundled" -- and its answer: the Compose app draws icons from its own committed Nerd Fonts subset, while iris was setting the disclosure mark with bare Unicode geometric codepoints out of whatever face the platform resolved. So iris now does what Compose does. - **`iris::icon`** (new module): the codepoints iris draws, one constant each -- `OPEN`, `CLOSED`, `COLLAPSE` today. Every one has to have a matching entry in `iris/core/build-icon-font.sh`'s `GLYPHS`, which is what builds the shipped `iris/core/assets/fonts/nerd_icons.ttf` (992 bytes, Material Design, Mono face). `every_icon_is_in_the_bundled_font` fails the build if the two lists drift. - **`Family::Icons`** (new variant): how any text asks for that family. Before/after: // was mark(if open { Dir::DOWN } else { Dir::RIGHT }, 9.0, MUTED) // now text(if open { icon::OPEN } else { icon::CLOSED }, 9.0, MUTED) .family(Family::Icons) It names an intention, not a font name: only `TextData` knows what the bundled file registered as, and it resolves the variant during shaping (`TextData::resolve_family`, also public). A *named* family rather than a generic one, so nothing falls back into it for ordinary text and an icon cannot fall back out of it onto a system face that happens to have the codepoint. - **`iris::widget::mark` is removed** -- added earlier the same day and superseded within it. It drew one correct triangle; every further icon would have been another rasteriser, and an icon as text takes the size, colour and baseline of the line it sits in for free. - **`FontDiagnostics::icon_family`** (new field), in the startup log line and the Diagnostics pane: which family the icons resolved to, so a build whose bundled font failed to register says so instead of drawing tofu. This does not reopen the 2026-09-07 platform-fonts decision. Body and monospace text still come from the platform's own collection; an icon is the opposite case, a small closed set of codepoints no system font is guaranteed to have, and it is the same division the Compose app makes. ## 2026-09-08: a press only reaches what the pointer is actually on Iris's report -- "if I try to scroll vertically while a horizontal scroll animation is still active, it stays locked to the horizontal scroll. It should let it keep going and instead only affect vertical scrolling" -- and her own diagnosis of it, which was the right one: "it seems like iris is set up so the animation stuff is global which it definitely should not be. Tapping outside of something that a fling is currently active for should have no code in common with the fling that could influence it." It was global, and it was in `sense::should_run`. `run_sensors` runs a widget one frame *after* the pointer leaves it (`ActivationState::End`, which is not `Off`) so a `HoverEnd` can fire, and `should_run` derived `PressStart`/`Pressing`/`PressEnd`/`Scroll` from the raw button and wheel state without consulting `hover` at all. So that farewell frame carried a press to a widget the finger was nowhere near. That alone would have been a stray event; what made it eat the gesture is the catch added on 2026-09-07 (`PressState::scrolling`), which commits a press on already-moving content to a pan immediately, with no `DRAG_SLOP` -- so the widget captured the pointer on that frame and every later sample went to it. And the widget's hover was stale in the first place because a gesture that ends while captured returns from `run_sensors`' capture branch, which never reaches the loop that would have updated it. Measured on the real screen before the fix: a fence flicked sideways, then a finger put down on a row **500px above it** and dragged 160px down the screen. The list moved by zero, the fence moved by zero, and the fence held the pointer for the whole gesture -- the report, exactly. - **`should_run` now requires `hover.is_on()` for every non-hover sense.** Press and wheel both, since a wheel event reaching a widget the cursor has just left is the same fault with a different sense. `Drop` and `Cancel` are unaffected: they are delivered deliberately to a widget that is *not* under the pointer, and `run_sensors` hands both an explicit `On`. - **`Scroll::is_scrolling`** (new): whether a fling is coasting in this area, the same question and the same name `List::is_scrolling` already answers for the other scrolling widget. Nothing about the fling, the arbiter or the catch changed. A press outside a coasting area now has no code in common with it, so the horizontal fling keeps coasting through a vertical drag on its own -- which is the second half of what Iris asked for, and it falls out of the fix rather than being arranged. A press *inside* a coasting area is still a catch on either axis, which is what Compose does ("Compose does catch no matter what axis if you tap in the horizontal area"). Two tests, one per layer: `sense_tests::a_press_does_not_reach_a_widget_the_pointer_has_just_left` is the mechanism with two stacked scroll areas and no screen, and `fence_fling.rs`'s `a_drag_away_from_a_coasting_fence_scrolls_the_list_and_leaves_it_coasting` is the report itself over the real transcript. Both fail on the old code. ## 2026-09-08: the composer is clipped to its bar, not inside its padding Iris: "the message input box doesn't clip correctly ... the box should be clipped rather than the inset text." The composer was `.masked().background(rect(...))` -- two boxes, one inside the other. The mask sat *inside* the `dp(FIELD_PAD_DP)` padding, so a message longer than the six lines shown was cut through the middle of a glyph 12dp in from the bar's edge, with a band of bare surface above the cut. Measured at the phone's own size and density (1080x2424 at 2.55): the bar's top edge at y=1995.6 and the text sliced at y=2026.2. It is `.masked_by(rect(BAR_FILL))` now: the same rect is the surface drawn behind the field *and* the shape the field is clipped to, so the two cannot fall out of step -- the idiom `row.rs` already uses to cut a code fence to its own rounded panel. Text now disappears under the bar's edge at 1995.6. The padding still holds text off the edge at the end the content is anchored to, which is the end anybody is reading. The composer's overflowing and keyboard-open states had no way to be looked at headlessly, since that window has no keyboard: the phone rig takes `--message TEXT` and `--ime PX` for them (`transcript-fixture/examples/phone.rs`, through `RUN_HEADLESS_ARGS`).