RUST.md's "Three test layers" section rewritten in place with what was
built: the `cargo test -p transcript-fixture` command and the five
assertions with the mutation that fails each, the `run-headless.sh
--phone [--replay …]` commands and the 15s/18s they take, and a
paragraph on what still cannot be answered below layer 3 (anything about
pixels, any frame time, anything JNI). Also the two traps that cost time
-- `swaymsg seat - cursor` reaching nothing on a compositor with no
input devices, and a leftover window tiling beside the new one so a
screenshot looks like a duplicated-primitive bug.
IRIS.md gains the public surface: `iris::harness`, `TouchScript`,
`List::fling_velocity`, the fling's clock, and the desktop backend's
move to physical-pixel layout with `content_scale`/`IRIS_SCALE`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Iris: masks should carry a shape, rounded rectangle first, or take a
container widget as the mask, with corner alpha multiplied rather than
cut. Design: the mask evaluates the same SDF draw_rounded_rect uses,
nested masks chain and multiply like moves, and a rounded Rect's
.masked() makes the container the mask with one radius by construction.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
RUST.md's "Shell lost" section and IRIS_TODO.md's matching paragraph both
said item 4's fix was written but never built or tested. It was committed
in ba2afba with its test passing, so both were stale the moment that
landed and read as if nothing had been run at all.
Replaced with one section per item, saying what was fixed, what was
measured on this checkout's emulator and what the phone still has to
settle: items 2 and 3 ticked with their numbers, item 4 ticked on the code
with phone confirmation still owed (no Vulkan adapter here), item 1 left
open with the exact logcat line for Iris to look at. The two pre-existing
faults found on the way -- the 16-deep move chain and the API-29 JNI calls
-- are recorded where the next reader will hit them.
IRIS.md gains the public-surface entry: `Widget::tick`,
`UiData::animate`/`tick_animations`, `FlingCalculator`'s density and
coefficient, and `MOVE_CHAIN_LIMIT`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
P1b (docs/RUST.md). `transcript-ui/src/tool.rs` draws a card per tool
call and a group per run: collapsed, a card is its name and the one-line
summary `parse_tool_input` derives; open, it is the description, the
input (highlighted, on the verbatim surface) and the output, capped with
a "Show all N lines". A run is one surface with a heading and a chevron
bar at its foot, so it closes from either end.
Three things worth knowing.
**A collapsed card lays out its summary line and nothing else.** The
fixture's tool outputs are tens of kilobytes and a collapsed card never
builds a widget for one -- `collapsed_cards_shape_only_their_summary_
lines` opens a three-card group over 88 kB of output each and asserts the
text-shape count equals the same group's over three bytes (17 either
way; 17 against 20 when the discipline is deliberately broken, so the
test is real).
**A result arriving replaces one card.** `ToolRow::apply_calls` is the
group's half of `RowBlocks::apply_delta`'s rule, and `build_row` now
hands back one `TailRow` -- blocks for a message, cards for a run --
rather than two mechanisms chosen at each call site.
**Every tap is a tap**: `GestureOutcome::Tapped` out of the `DragArbiter`
`Selection` already owns, so a drag that started on a card scrolls the
transcript instead of opening it.
Three defects found by looking at the render, all recorded with their
repro in docs/IRIS_TODO.md: a `Span` of padded children inside another
`Span` places them a slot out of step (worked around by building the
group as one span, which costs the 4dp inset); `scrollable_on(Axis::X)`
on a non-editable text draws nothing, so a card's command is clipped
rather than pannable; and `NotoSans-Regular` has no U+25B8/25BE/25B4 at
all, so the expander mark is set in the monospace face.
Screenshots: docs/bench/p1b-2026-09-06/.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`transcript-ui::tool` draws a card per call and a group per run, with
the states, the collapsed-lays-out-nothing discipline and the
one-card-per-result update. Screenshots in docs/bench/p1b-2026-09-06/.
Includes a local fix to `List::place`'s reposition-vs-mov clash, which
is about to be dropped for rustify's own.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`mov` accumulates a delta onto the slot and `reposition` overwrote it, and
both legitimately land on one widget in one frame: `List::place`'s
Bottom-known branch offers a row a same-size box that has moved (`mov`),
then corrects the placement inside it when the row's cached height no
longer matches what the row reports (`reposition`). That is what a wrapped
transcript row hit, and what the `move_applied == ZERO` debug assert was
standing in for -- an assert against a case that happens is not a
guarantee, it is a crash.
The slot means `move_applied + repositioned` now, both halves recorded on
`ActiveData`, so `reposition` adds the move rather than dropping it and
stays idempotent. The assert it replaces is a `debug_assert_eq!` that the
slot still holds that sum on entry -- i.e. that nothing but those two ever
wrote it.
Test: `a_widget_moved_by_its_parent_and_then_placed_inside_it_lands_at_the_placement`,
which draws the child at the offered position (-100px) rather than the
placement (100px) without the fix. Verified against the `.wrap(true)`
repro from docs/IRIS_TODO.md (draws correctly, no panic) and an emulator
bench run with assertions live.
The emulator was blamed for two days for what is iris's own defect on any
GL adapter. `GpuTextures::new` created the atlas `texture_2d_array` with
one layer; wgpu-hal picks the GL target from the descriptor alone
(`gles::Texture::get_info_from_desc`, `(false, 1) => TEXTURE_2D`), so the
shader's `sampler2DArray` was handed a `GL_TEXTURE_2D`, the unit was
incomplete, every `textureSample` returned (0,0,0,1), and `draw_glyph`'s
`color.a *= texel.a` painted the whole glyph quad.
`MIN_ARRAY_LAYERS = 2`, with the account at `create_array_texture` and a
`debug_assert!` there. Vulkan -- the phone's build and the desktop's
default backend -- was never affected.
`force-gles` now switches the desktop backend too, so the GLES path is
reproducible on a machine with a real GPU in seconds rather than only
through an APK: that is how this was found, with two shader probes
showing the sample was exactly (0,0,0,1).
The defect P1a's screenshots found, and the one that mattered:
`Rect::is_size_independent()` answered `true`. A `Rect` fills whatever
region it is handed, so its content *is* the region -- and
`draw_inner`'s fast path, which rewrites a widget's primitives with
`r.outside(&from).within(®ion)` instead of redrawing it, cannot
reproduce that once a region carries both `rel` and `abs`. What it
looked like: a fenced code block's background kept the height of the
provisional full-region draw `Span` does in its first phase, so one
fence's panel covered every block below it and every row below that,
with the text underneath laid out correctly. Likely the same cause as
RUST.md's older "the composer bar's grey background is not drawn".
Also here: a quote's bar is a `Stack` background behind padded text
rather than a two-child `Span(Dir::RIGHT)` (one widget fewer and no
provisional pass), and `transcript-ui`'s `transcript` example gains a
row holding one of every block kind -- the fixture's own heading,
paragraph, fence and table source, plus a list and a quote, which the
fixture has neither of.
docs/bench/p1a-2026-09-06/ has the pairs and docs/RUST.md's P1a box
names what still differs. The iris half is from the desktop backend
because this emulator cannot draw iris's glyphs at all (solid boxes,
reproduced on the previous commit, with Compose drawing text correctly
on the same AVD); both routes to Vulkan on this AVD were tried and both
fail. Bench stream phase, assertions live, no abort: p50 53.0ms p90
108.6ms p99 132.0ms against 52.8/108.1/137.3 before -- unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
RUST.md gains the pass's findings with their commits and the numbers:
the block model held under a per-character prefix property, the
size-independent hit-box defect and its fix, the tail-rebuild selection
gap, why the three new debug_asserts are whole-set, the text-shape
counter that turns "a delta costs one block" into a measurement, and the
verification bench run.
IRIS_TODO.md's "the bar's own grey background is not drawn" is
withdrawn: decoding the screencap puts it at rgb(41,40,49), full width,
y2245..y2365 -- drawn, and dark on black, which is most likely what the
earlier reading was.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A row was one TextEdit holding the whole message, so every delta
re-shaped every paragraph of a long reply through parley -- the one phase
where iris trails Compose on the phone (p50 18.2ms vs 13.4ms, bench v2).
- client-core/src/markdown_blocks.rs: split a message into its top-level
blocks with their source, through the same pulldown-cmark the renderer
parses with so the two cannot disagree about where a block starts, plus
common_prefix. Appending markdown can rewrite an earlier block (a
trailing --- turns the paragraph above into a heading), so the fast
path compares the prefix it keeps rather than assuming it -- with the
test that says so.
- transcript-ui: a row is a Span of one TextEdit per block;
RowBlocks::apply_delta replaces the block a delta lands in;
TranscriptScreen keeps the tail row's blocks, seeded in build_tree as
well as push_row (a screen opened onto a streaming reply took the
rebuild path for its first delta otherwise, with nothing to say so).
- A block is the selection unit: Selection is keyed by (RowKey, u32),
which is reading order at both levels, and the pointer-captured half of
a drag resolves the block under the finger from its drawn box
(Selection::locate) instead of from the row's extent.
Pass condition: a_delta_into_a_long_reply_redraws_the_same_widgets_as_a_short_one
drives a real UiRenderState and asserts the draw count for a delta into a
100-paragraph (3,000+ char) reply equals the count for a one-paragraph
one. 30 either way; it read 630 against 30 twice on the way there.
Emulator stream phase, same AVD before and after: p50 61.5 -> 54.5ms,
p90 211.7 -> 113.1ms, p99 342.6 -> 137.4ms, worst 403.6 -> 143.0ms, 202
-> 293 frames in the same 21 seconds. Selection across blocks verified
with a real long-press drag.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Wrapping the composer's field in .scrollable().masked() needed three
layout defects fixed first, each with a headless regression test that was
confirmed to fail without its fix:
- MaxSize/Sized reported a caller's declared dp length unresolved, and
Span places a child from the abs/rel of what it reported, so dp(168)
was worth zero: the bar got a slot of nothing the moment its content
passed six lines and the Scroll inside measured its container at -63px
(container=-63 content=415.8 amt=478.8 on the emulator). Len::fold_dp,
used on the way out, plus a debug_assert in draw_inner that a reported
Size carries no dp -- the rule is about every widget, not those two.
- Masked allocated a fresh mask slot per draw, and draw_inner's
unchanged-region fast path does not revisit descendants, so they kept
clipping against a box the bar had moved away from: four live mask
entries, none of them current, and the field drew nothing.
ActiveData::own_mask, allocated once and rewritten in place.
- mov updates active.region and accumulates the same delta on the move
slot, and resolved_region added both, so a panned widget's own hit box
sat at twice the pan -- the composer's field was untappable after a
drag. ActiveData::move_applied.
Scroll itself measured the right number by a misleading route; it is
written against painter.px_size() now and still reports its content's
size, since reporting the container makes the answer a function of
itself.
Verified on this checkout's emulator: swipe 540 1200 -> 540 1460 moved
the field's Message box 31,1041..1048,1509 -> 31,1131..1048,1651 with its
height unchanged at 468px.
run-bench.sh polled logcat for a prefix copy_report also logs at startup,
so it printed a report that had never been run.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
IRIS_TODO.md's "the composer has no touch-drag scroll". `Scroll::drag`
takes its pan from the same `sense::DragGesture` `List` is driven by --
arbitration, DRAG_SLOP, velocity and pointer capture all stay in sense.rs
and only what a committed pan *means* is decided per caller -- and
`WidgetLike::scrollable()` registers it beside the wheel handler it already
registered, so every scroll area pans on a finger with nothing added at the
call site. No fling: `Scroll` has no per-frame tick to animate one and the
areas it wraps are at most a screenful. `Scroll::amt()` exposes the pan
position.
`attr.rs`'s `on_press` treated an already-focused field as the plain
click_or_drag case, so every Pressing frame extended a selection. It now
applies the same DRAG_SLOP rule its unfocused branch already did: a press
past the slop vertically abandons its pending selection for the rest of the
gesture, so the scroll area around the field wins it. That is Android
EditText's own behaviour and it is what lets a swipe up over the composer
scroll instead of dragging a highlight through what you typed.
Also fixed, found doing it: `ActiveData::mask` stored the mask a widget
*set* rather than the one it was drawn *under*, and `redraw` feeds that
field back in as the inherited mask -- so a targeted redraw of any `Masked`
handed it its own mask and aborted on `set_mask`'s nested-mask assert. A
real abort on the emulator, `assertion failed: self.mask == MaskIdx::NONE`.
And the per-frame orphan guard from 76b1f99 is now a count comparison
(O(active widgets)); the O(primitives) walk only runs to build the failure
message, because running it per frame made a debug build on the emulator too
slow to finish a bench run at all.
Tests: four in scroll.rs (pan past the slop, a tap inside it, a horizontal
drag, the end clamp), `a_finger_drag_over_a_scroll_area_pans_it` in
sense_tests.rs driving the whole registration/dispatch/capture path (fails
with "got 0" without the new registration), and
`redrawing_a_masked_widget_does_not_nest_its_own_mask` in layout_tests.rs
(aborts on the pre-fix code).
The composer itself is deliberately still not `.scrollable()`: `Scroll`
measures against the window rather than its own offered box, so inside the
`MaxSize` capping it at six lines it pans the field out of the bar --
measured, reverted and written down in RUST.md and DECISIONS.md.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
TextEditCtx::select compared the tap against the laid-out text's own box
and cleared the selection for anything outside it. An empty field lays
out to a zero-width box, so tapping the composer granted focus and opened
the keyboard with no caret, and insert_str returns early without one --
every keystroke went nowhere and no glyph was ever emitted. Parley clamps
a point outside the layout by itself, and a press reaching select() has
already been hit-tested to the widget, so there was nothing for the
'outside' branch to mean.
insert_str now debug_asserts rather than dropping input silently, and
UiRenderState::draw_started -- a re-entrancy guard whose test was written
after its own remove(), so it could never fire, and which grew by one
entry per widget ever drawn -- is restored to what it was meant to be:
inserted around Widget::draw, removed when it returns, asserted empty at
the top of every update.
The empty benchmark-report TextEdit held .height(rest(1)) beside
content.height(rest(2)), so it reserved a third of the window at every
launch and pushed the composer two thirds down -- Iris's 11:39 phone
report. It is sized to its content now, capped and scrollable, and sits
above the transcript rather than under the composer.
New log::info! lines for one insets change, one surface_changed, one
renderer build and one surface_destroyed, each with the glyph/atlas
counts, so a phone's adb logcat can answer the app-switch text loss the
emulator cannot reproduce.
Finding 1 (the real crash): Selection::clear() drops rows and anchor,
called from TranscriptScreen::apply's Rebuild arm right before
List::clear() -- push_row re-registers survivors as it rebuilds each row.
Fixes a WeakWidget outliving the row group_tool_runs regrouped away,
which panicked the next long-press anywhere. New apply_tests test builds
a real TranscriptScreen, forces the regroup, and confirms no panic.
Findings 2-5: debug_assert!s on List::place's slot, List::fling and
FlingCalculator's velocity finiteness, VelocityTracker::add_sample's
chronological order, and FrameReport::mark_phase's non-decreasing
start_index. Finding 7: bench_client.rs's battery_line guard restructured
so the empty check can't be separated from its unwraps by a future edit.
Findings 9/10: new List tests pinning tick_fling's per-tick deceleration
and replace_back's evicted-key cleanup with a different key than the
existing tests use. IRIS.md's replace_back/clear/apply entry gained the
side-table-clearing note the Docs finding asked for.
Also records this pass's DragGesture-merge verification in RUST.md (tap
stays vs swipe doesn't, a real fling keeps moving after release, keyboard
cycles confirmed via on_insets_changed) and annotates the two IRIS_TODO.md
phone-report items it targets.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review of 73251d6's port of TranscriptSource/joinPages.
`TranscriptSource::page` answered `before == 0` with an empty `Vec`, which
is the same value it answers "this conversation has no more history" with.
That is the state the Kotlin keeps apart: `loadOlderPage` returns false at
`oldestSeq == 0` *without* touching `moreHistory`, and returns false on an
empty page *by latching it*. Collapsing the two moved AGENTS.md's paging
bug one layer down rather than fixing it. `page` returns `OlderPage` now --
`Events(vec![])` is the start of the conversation, `NothingLoaded` is not
an answer about the conversation at all.
`join_pages`' `debug_assert!` on seq ordering across the boundary is not a
true invariant: a peer note carries the seq its turn began at, which can be
older than the page it arrived in, so an ordinary transcript would have
panicked a debug build there. Replaced with the one the function exists to
enforce -- no tool id surviving in both halves.
`fetch_transcript_lines` stores `RawValue`'s exact server bytes, so the
"neither source can produce a newline" comment in `SessionCache::append`
now rests on the server's serializer staying compact rather than on a
local normalization. Checked with a `debug_assert!` in `append` and
`store_page` rather than trusted.
Tests for the failure half, which the port had none of: a 500 mid-page, a
cached line this build cannot read, and the `after` bound in the case that
actually carries one (the existing test asserted only the case with no
bound). `cargo fmt`, `cargo clippy --all-targets`, `cargo test` (112) clean
in client-core; `cargo check -p desktop-app` clean.
Closes docs/RUST.md's "client-core prerequisites for P1" box: the
cache-vs-server stitching TranscriptSource.kt does, and the
joinPages/healSplitMessage/adoptRun page-boundary healing
TranscriptItems.kt does, both ported into client-core with no UI
framework dependency.
Neither Kotlin file had a JVM unit test of its own, so the port used the
Kotlin source and AGENTS.md's "things that have bitten" paging incidents
as the spec instead of a test-for-test transcription. Both regressions
get a dedicated test: TranscriptSource::page refuses before == 0 before
touching the cache or the network (loadOlderPage's incident), and
adopt_run now runs on every page join rather than only the one where a
split call was found (the "one run drawn as two" incident).
fetch_transcript_lines (api.rs, additive) pairs each transcript line with
the exact server bytes via serde_json::value::RawValue rather than
re-serializing a parsed Value, so a cached line and a live SSE frame for
the same event agree byte-for-byte -- the fetch_transcript_page other
callers under iris/ depend on is untouched.
client-core: 85 -> 109 tests. cargo test/clippy --all-targets/fmt clean.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Four fixes from Iris's phone report on the dc01f88 build, plus her same-day
follow-up on swipe-vs-tap:
- android/ime.rs: InputConnection now calls InputMethodManager.updateSelection
after every edit (new update_ime_selection, called from after_input) -- Gboard
was holding keystrokes back with nothing telling it the app's selection/
composing region had moved, which read as "doesn't enter it until I hit
space, doesn't move the caret". New unit tests in widget/text/edit.rs cover
the buffer-level composing/commit/delete/selection operations directly.
- attr.rs: Selector/Selectable rewritten around a shared on_press dispatcher
over PressStart/Pressing/PressEnd instead of click_or_drag(), so a field
that isn't already focused only grants focus (and requests the IME) on a
completed tap -- press and release with no frame past DRAG_SLOP. A drag
is never consumed, so whatever is behind the field still sees it. New
FocusHost::is_focused (both platform impls) and TextEdit::press_origin
back this. Verified on the emulator: dumpsys input_method's mInputShown
stays false after a swipe over the composer, true after a tap.
- iris_core: GlyphAtlas::clear()/Textures::reset(), called together from
android/view.rs's surface_changed exactly when a genuinely new renderer is
built (app-switch, not the keyboard-resize path that already reuses the
renderer) -- both CPU-side caches otherwise kept pointing at the old,
destroyed device's textures. Verified on the emulator: home, reopen, every
glyph still on screen.
- transcript-ui/composer.rs: rebuilt as one widget (unchanged Stack{rect,
span} idiom, capped at ~6 lines via MaxSize + .scrollable(), wrapped in one
Pad whose bottom Composer::set_bottom_inset rewrites in place so the bar
sits on the IME or nav-bar inset with no rebuild -- rebuilding would drop
focus/selection/in-progress text). Wired from bench_client.rs's existing
on_insets_changed.
A second, deeper bug found while verifying the composing fix is NOT fixed
this pass: composed text never becomes visible at all. A new layout_tests.rs
test proves the widget tree's own region math is correct across a keyboard
resize, ruling that out; RUST.md's P0 box has the full writeup and what to
check next (UiRenderState::redraw's single-widget path, or something
force-gles-specific -- this AVD has no Vulkan adapter to rule that out with).
cargo fmt/clippy/test --workspace and cargo ndk clippy all clean.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two follow-ups after the keyboard/dp/header pass, both requested against
the P0 box:
(a) The header row rendering a second time inside the transcript area
after a keyboard-triggered resize: reproduced reliably (tap the composer,
screenshot after the keyboard opens). Ruled out one concrete hypothesis --
on_insets_changed rebuilding top_bar on every ime_bottom change, unrelated
to the header's own status-bar padding -- with a guard (last_top_pad) that
reproduced the identical duplicate afterward, so repeated rebuilding is
not the cause. Kept the guard as a real (if insufficient) fix for needless
rebuilds. Not root-caused: Span's two-phase provisional/real draw and the
redraw_all-vs-redraw_updates split are the two live suspects, but pinning
which one (or something else) produces the duplicate needs instrumenting
draw_inner directly or the phone. Full writeup in RUST.md's P0 box.
(b) Why on_insets_changed's ime_bottom never confirmed the keyboard being
shown, on either the auto-diagnostics or the new bench keyboard phase:
MainActivity.java uses windowSoftInputMode="adjustResize", under which
WindowInsets.Type.ime()'s own inset amount is defined to read zero (the
window already resized to avoid the overlap that inset would describe) --
the same trap AGENTS.md already names for the Compose side. Fixed to read
insets.isVisible(ime()) instead, a boolean unaffected by resize-vs-pan.
This alone did not make the callback re-fire on this emulator, which
still shows no insets callback after the initial one at attach -- named
but unconfirmed hypothesis: a non-edge-to-edge Activity may not get insets
redelivered for a pure IME toggle handled via resize, needing an edge-to-
edge opt-in this pass did not attempt given the risk to adjustResize's
own behavior.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
run-bench.sh end to end clean (24/24 swipes, 400/400 events); header
background confirmed by screenshot; the keyboard wipe fix confirmed two
ways (a forced wm size resize and an actual soft-keyboard open, both real
surface_changed triggers, text intact both times).
Also records two things found during this verification and not fixed:
the top button row appears to render a second time, out of place, after
a keyboard-triggered resize, and a tap aimed at the field below can land
on it instead -- and the keyboard diagnostics auto-capture never fired in
this session. Neither is root-caused; explicitly not attributed to this
pass's changes without more evidence, per the standing rule against
blaming ambient failures on your own code without measuring first.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
docs/IRIS.md's 2026-09-06 entry (public API), docs/LAYOUT.md's "Density:
Len::dp" design section, IRIS_TODO.md's density-unit item ticked, and
docs/RUST.md's P0 box gets the investigation: the keyboard-wipe
hypothesis and confirmation, the blur root cause and why the dp unit
turned out to be the same fix, the header cause, and what remains
unverified (an emulator screenshot of the keyboard fix, and Iris's real
phone).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Adds VelocityTracker and a port of AOSP SplineOverScroller's fling curve
(FlingCalculator, cited at the definition) to iris::sense, and wires
List::fling/is_scrolling/cancel_fling/tick_fling through
Selection::drag's release path -- a pan's release now decelerates instead
of stopping dead on the finger lifting, matching IRIS_TODO.md's "swiping
has no momentum" ask. Clamped at the loaded content's start/end and
cancelled by the next touch-down.
Also fixes the scroll jitter DragArbiter's slop release caused: crossing
DRAG_SLOP applied the whole pre-threshold drag (measured from press_start)
in one step, since nothing pans while a gesture might still resolve to a
selection. Now only the excess past DRAG_SLOP is applied on that frame,
the same way Android's own touch handling consumes touch slop rather than
replaying it.
Root-caused by reading DragArbiter's state machine and covered by new
unit tests (fling distance against the closed-form spline result within
1%, cancel-on-touch, start/end clamp, the slop-crossing regression); no
emulator was used this pass, so an on-device trace/feel-check is still
open, and Benchmark v2's four-phase bench_client.rs spec was not
attempted. docs/IRIS.md, docs/IRIS_TODO.md and docs/RUST.md's P0 box
record what's done and what's left.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
RUST.md's P0 box gets Iris's first real-phone report (no crash) and the
four defects it found (glyph-wipe-on-first-touch, missing bold glyphs,
text far too small, status-bar inset not applied), what was fixed and
how it was verified on the emulator, and what's still open (item 1's
root cause, and the top-row height anomaly noted in the last commit).
IRIS_TODO.md gets a new "From the phone, 2026-09-06" section for the two
items explicitly deferred to a follow-up agent: no scroll momentum/fling,
and occasional jitter scrolling down.
IRIS.md gets the public-API entry for TextData's bundled fonts/
font_diagnostics, UiRenderNode::new/resize's new window_size parameter,
AndroidUiState::content_scale, AndroidAppState::on_insets_changed, and
iris_core::WgpuErrorLog.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Iris's ask (2026-09-06): the fling should travel much faster for
stress-testing, plus typing and keyboard phases. Written once into the
P0 box so the iris agent implements the identical four-phase spec --
constants, ordering and report shape -- rather than a second one that
looks the same but isn't.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
UiRenderNode::new used to let a wgpu validation error reach the default
uncaptured-error handler and panic, which is what aborted the P0 bench APK
on Iris's phone in AndroidRenderer::new with only "wgpu error: Validation
Error" surviving into the truncated crash report. It now wraps creation in
wgpu error scopes and returns Result<Self, String>; the Android backend
turns a failure into the adapter's identity, the limits/downlevel flags a
layout validates against, and wgpu's own error chain, logged as one logcat
line and shown on screen (IrisView.showRendererError) instead of crashing.
Auditing every bind-group-layout entry against wgpu-core's own validation
source names the likely cause: masks_layout's move_offsets storage buffer
is visible to the vertex stage, which Vulkan grants unconditionally but
GLES gates on the driver's own vertex-stage SSBO support -- and the
delivered APK was built with force-gles, a flag meant only to force the
*emulator* onto GLES for one frame-time measurement, that build-apk.sh's
default feature list applied to every arm64 build regardless of target.
Its default no longer includes force-gles.
Testing the diagnostic (by inducing an artificial validation error) also
found and fixed a real reentrancy bug: calling Activity.setContentView
synchronously from inside a ViewPeer callback re-enters the same peer's
RefCell borrow through onFocusChanged, aborting with "RefCell already
borrowed". Deferred through the same push_dynamic_deferred_callback
mechanism raise_if_enabled already uses.
Full audit, verification, and the named hypothesis are in RUST.md's P0
box ("iris bench crash on the phone, 2026-09-06"); the API change is in
IRIS.md. Nobody on this session has the phone, so this is unconfirmed
against real hardware -- the point of (1) is that the next run says so
either way.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Ran three clean iris-scroll.sh passes on a cold -gpu host boot (all
24/24 swipes confirmed scrolling via clustered render() timestamps, not
inferred from frame count) and retook the host-GPU table's iris row as
a best-of-three. EMU_GPU=software + force-gles still cannot produce a
GLES number on this hardware -- after the earlier compute-limit crash
was fixed, device creation now aborts on max_storage_buffer_binding_size
instead (SwiftShader ES 3.0 has no SSBOs, and shader.wgsl reads four
var<storage> buffers unconditionally), so the SwiftShader-Vulkan-vs-GLES
question is closed as structurally unanswerable rather than answered.
A fresh cold-boot run-bench.sh reading for P0's bench build is in line
with the earlier warm-AVD readings, closing that box's own caveat too.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
RUST.md's P0 box gets the fix, the before/after streaming-phase numbers
(with their caveats), the build-apk.sh/run-bench.sh scripts, and what the
dropout-fix pass's three remaining verifications are blocked on (the
sandbox ai-server currently fails to build, unrelated to this change).
IRIS.md gets the List::replace_back/clear and TranscriptScreen::apply
API entries. AGENTS.md's rigs section gets one sentence on each script.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>