Commit Graph
497 Commits
Author SHA1 Message Date
irisandClaude Fable 5.1 3c80d9d696 iris bench: a Trace switch for the input/frame diagnostics, and the report says when it was on
`iris::diagnostics::set_trace` landed with nothing to press it. It is the
bench header's fourth control now, reading `Trace off` or `Trace on` --
a toggle whose own appearance never changes is a button that looks like
it did nothing. Its accessibility label stays the fixed "Trace input and
frames", because that is what `run-bench.sh` and `ui-trace --do "tap
'...'"` find it by and a control that renames itself when pressed is one
no script can find twice. Pressing it rebuilds the header and shows the
diagnostics pane, so the state is on screen at the moment of the press.

Both reports carry `trace_line`, from the flag read at the *start* of
what is being reported as well as at the end: the switch is on screen
while a benchmark runs, so "somebody moved it half way through" is a
state that happens, and reported as either "on" or "off" it would be a
confident sentence about a log covering half the run.

The row's type size is one constant for all four labels and drops from
18 to 13: with a fourth control the labels overlapped each other on a
1080px screen. Shrinking one label to fit is what the UI rules forbid;
resizing the row is a layout decision and all four still match.

Checked on the emulator: the switch flips its own text and colour, the
pane reads "input/frame trace: on", and `iris::frame`/`iris::input`
lines appear in the ring only after it is pressed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 20:58:58 -04:00
irisandClaude Fable 5.1 06b8a1f4b0 The app hands its log to Dev Updater on the phone, not through ai-server
Iris's call once the upload route was working: put it in Dev Updater
properly. So the app now exposes its own ring through a ContentProvider
at `<applicationId>.devlog` -- Dev Updater's contract, written down in
that project's README, not something invented here -- and Dev Updater's
phone app reads it on the same device and forwards it to its own build
machine. No tunnel, no token, no second enrolment, and any app that
server delivers can implement the same and get the same Runtime tab.

`DevLogProvider.java` plus `devlog.rs` are the platform glue only: a flat
`String[]` across JNI, a `MatrixCursor` on the Java side, and
`nativeReady` telling Rust the authority the provider actually
registered, so the Diagnostics pane can name somewhere a reader can
query rather than composing a guess. `LogRing::newest_seq()` is the one
addition in `client-core`: an in-memory ring starts again at zero, so it
is what lets a reader notice the process restarted instead of silently
skipping everything since.

Deleted with it, so there is one mechanism: `client_core::log_upload`,
`POST /client-log` on ai-server, the `AI_APP_LOG_*` baking (which left
`build.rs` with nothing to do), and the uploader on both Android
clients. Kept: the ring, `RingLogger`, `install_process_logger`, and the
Diagnostics line -- whose second half is now `devlog provider:
content://<authority>`.

Verified end to end on this checkout's emulator: iris's own
`iris::android::view` startup lines read out of the provider by the
shell, forwarded by Dev Updater's Runtime tab, and served back from
`GET /apps/android-app/components/app/logs?kind=runtime`. A component
whose package has no provider says so in as many words.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 20:58:48 -04:00
irisandClaude Fable 5.1 e10582a2cd iris: three layer-1 tests that could not fail in the direction the bug goes
docs/REVIEW-2026-09-07.md's T1, T2 and T3. Each was confirmed by breaking
its subject on purpose and watching the new assertion fire, and each of
those breaks is recorded beside the assertion.

**T1** (`phone_screen.rs`) bounded the fling's duration with
`FlingCalculator::new(PHONE_SCALE).duration(velocity)` -- the calculator
under test -- and only from above, so it could fail when a fling ran too
long and never when one stopped dead, which is the symptom Iris actually
reported. The companion `assert_ne!(before, after)` passes on one pixel of
travel. It now takes both bounds from `fling_spline_reference.py`, which
gains this case's own line (`density=2.55 v=15250.0: distance=11057.424px
duration=2.0716s`), and measures travel in pixels from a row's own
on-screen extent -- 10527px against the reference's 11057, the 5%
shortfall being the frames a tracked row leaves the screen on. Scaling
`tick_fling`'s elapsed by 1000 reports "stopped after 8ms"; scaling its
delta by 0.01 reports "travelled 111px".

**T2** (`top_edge.rs`) asserted the per-row box only on the return leg,
so a regression that drew rows in the wrong place while travelling
*backwards* was checked by the row count alone. The first leg still
cannot assert it (an unmeasured row has to be drawn to be measured), so
there is now a third leg -- back again, every height known. Widening
`intersects_viewport` downwards passes all 40 forward steps and fails at
"back 6", which is the leg that did not exist.

**T3** (`top_edge.rs`) asserted a mask exists and sits inside the list's
box, never that any row primitive references it, so a broken
`Mask::parent` chain -- what d507ae4 introduced -- left it green while a
code fence drew unclipped. It now walks every row primitive's chain and
requires the list's own mask slot on it (and rejects a chain that loops).
Forcing `Painter::set_mask`'s `parent` to `NONE` fails it with "clips to
[Id(1)], a chain that never reaches the list's own mask Id(0)".

Verified: `cargo test -p transcript-fixture` (12) and `cargo test --lib -p
iris` (103) pass, fmt and clippy clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 20:58:35 -04:00
irisandClaude Fable 5.1 551c01398f iris: the guards against silently wrong output survive into release
docs/REVIEW-2026-09-07.md's R1. Every invariant guard added on 2026-09-07
was a `debug_assert!`, and every build anybody runs on this project is
release -- the bench APK must be (the debug `libmain.so` is 325 MB and
will not install) and Iris's phone gets release too. So a `List` drawn
without a mask painted over its surroundings again, in exactly the build
the fault was found in, with nothing saying so.

Promoted to `assert!`, each O(1) or a handful per *draw* and each
protecting against output that is wrong on screen with no other symptom:
`List::draw`'s `painter.is_masked()`, `List`'s `extents`-are-on-screen
check, `Painter::set_mask`'s doubled-call check (the second call replaces
rather than nests, i.e. an unclipped widget), `Painter::glyphs`'s atlas
generation (glyphs sampled from coordinates now holding other letters),
and `List::fling`'s finiteness (one comparison per gesture; NaN
propagates into `deceleration_for`'s `ln()` and the fling never settles).

Left as `debug_assert!` and now saying so in a comment: `List::place`'s
slot-exists precondition (once per row placed per frame, and its release
failure is the `.expect` below rather than something wrong on screen) and
`poly_fit_least_squares`'s two preconditions (run on every velocity query,
with `MIN_SAMPLE_SIZE` and the `is_finite` check giving release a defined
outcome either way). `PointerClock::sample`'s ordering assert was already
annotated in 2ec0fee for the same reason.

Verified: `cargo test --lib -p iris` (103) and `cargo test -p
transcript-fixture` (12) pass in both debug *and* `--release`, which is
what says the promoted asserts do not fire on a real replayed flick;
fmt, clippy and `cargo ndk check -p iris` clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 20:52:45 -04:00
irisandClaude Fable 5.1 7e79ec11e0 docs: the fling's "before" velocity is what velocity_reference.py prints, 12250 and 12500
docs/REVIEW-2026-09-07.md's D5. Four places quoted 11750 px/s as the old
average estimator's answer -- for `flick-120hz.touch` *and* for the
press-plus-one-move-frame set, which are different sample sets, and one
number in both rows is the tell. `iris/benches/velocity_reference.py`,
which the same section says every number below it comes from, prints
12250 for the recording and 12500 for the two-sample set, and
`sense.rs:1406` already had the 12250.

Half of where 11750 came from is recoverable and is written down beside
the table: it is the recording's 196 px over 16.68 ms, a 60 Hz frame
rather than the 16 ms span the file itself records. That explains the
flick row; the other row was copied from it. The 1.30x ratio derived from
it becomes 1.24x.

Also settles the second disagreement about the same experiment (the
review's rule finding on the negative control): `sense.rs`'s doc comment
claimed reverting `velocity` to total-over-span fails "exactly this one,
the flick recording, and phone_screen.rs" while RUST.md said seven. Run
again today with the revert in place: seven in `-p iris` (the flick
recording, the accelerating flick, the horizon, the stopped finger, the
minimum sample count, both `drag_gesture` flick tests) plus
`phone_screen.rs`'s flick, everything else green. RUST.md was right and
the comment now says the same thing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 20:43:23 -04:00
irisandClaude Fable 5.1 2ec0fee84c iris: the input clock anchors on the first event's oldest sample, not its own time
docs/REVIEW-2026-09-07.md's D4. `on_touch_event` took its one anchor as
`(Instant::now(), event.event_time_nanos())` from the first MotionEvent the
view ever sees, and dated every later sample as `anchor_at + (sample -
anchor).max(0)`. An event's historical samples are by definition *older*
than its own event_time, so if that first event is a Move -- the Down went
to another view, or the view was attached mid-gesture -- its whole batch
clamps onto one instant: three samples at the same time make the Lsq2 fit
degenerate and the flick reads 0 px/s. In a debug build the ordering
debug_assert fired first, and it was comparing against `anchor_nanos`,
a value from a different event, so it was also the wrong comparison for
the first sample of every later event.

The arithmetic moves into `sense::PointerClock`, which anchors at
`now - (event_time - oldest_sample)` and carries the last sample seen
across events, so `sample()`'s ordering assert compares against the
previous event's last sample. It lives in `sense` rather than in the
android backend because `iris::android` is cfg'd out everywhere but the
device, and this is exactly the arithmetic that wanted a test off one:
`the_first_events_batched_samples_are_dated_apart` reports [0ns, 0ns, 0ns]
against the old anchoring.

The assert stays a `debug_assert!` and now says why in a comment: it runs
once per touch sample, hundreds a second on a batching 120Hz screen, and a
mis-ordered sample degrades a velocity rather than drawing something wrong.

Also drops the stale reference to `VelocityTracker::add_sample` in the
comment above it (the review's rule finding); the method is `add_position`.

Verified: `cargo test --lib -p iris` and `cargo ndk -t x86_64 -P 29 check
-p iris` clean, fmt and clippy clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 20:36:41 -04:00
irisandClaude Fable 5.1 992c472975 iris: iris::input/iris::frame diagnostics, and gating the four debug! lines that already drowned the ring
Iris asked for a button to copy raw input events and per-frame timings
through the same report Copy report already produces. sense::log_input_event
(one line per platform pointer sample, historical samples inline on
Android) and diagnostics::log_frame (one line per frame: frame number,
frame clock, time since last input, layout/draw durations, redraw kind,
primitives on screen, animating) both land under iris::diagnostics's
trace_enabled() gate, off by default since the ring is 2000 lines/256KiB
and either target at 120Hz fills it in seconds. report_to_touch.py turns
a report's iris::input lines back into a .touch file for harness/desktop
replay, round-tripped in transcript-fixture's input_log_roundtrip test.

Folds in docs/REVIEW-2026-09-07.md's D1: four older per-frame debug!
lines (android::view's two render() lines, list.rs's fling tick,
text/mod.rs's text render) were unconditional at Debug and, with the
ring's RingLogger recording everything the app's Debug install lets
through regardless of target, filled it before Copy report ever saw
anything else. All four (and sense.rs's drag-release-samples line) are
now behind the same gate. The same test proves both directions: tracing
off leaves zero Debug lines from a replayed flick, tracing on produces
the expected iris::input/iris::frame lines with real durations.

Not wired to a Diagnostics-pane button: bench_client.rs is open under
another agent. set_trace(bool) is the whole surface a control needs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:48:48 -04:00
irisandClaude Fable 5.1 729098756d docs: the CA travels in the enrol link, and why not the two alternatives
DECISIONS.md gets the decision with both rejected options and what the
longer link measures (89 -> 652 bytes, a 45x23 QR -> 93x47), RUST.md ticks
the enrolment queue item and marks the log-upload route superseded rather
than editing it, and IRIS.md says what changed for anyone building the
Android app.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:47:48 -04:00
irisandClaude Fable 5.1 d8562d96a3 iris android app: told which server by an enrol link, not by its build
The APK is cross-compiled here and run against the server on the host, so
everything build.rs baked in (AI_APP_TRANSCRIPT_HOST/_PORT/_TOKEN and this
machine's CA) was good for exactly the pair that built it -- and a token in
a delivered artifact besides. MainActivity registers aiapp://enroll, hands
the URI and the app's private files directory to Rust, and
client_core::config stores it 0600; transcript_client reads it afresh per
transport, so opening a new link repoints a running app.

Diagnostics says which of three things is true, because they want different
actions: 'enrolled: host:port', 'not enrolled -- open the enrol link from
Dev Updater', and 'enrolment unreadable: ...' for the case nothing could be
found out. The last is why status() has an Unknown arm at all.

ui-sandbox.sh's printed enrol command now carries the CA, which is what
makes it work for an app with no baked copy.

Verified on this checkout's emulator: fresh install reads 'not enrolled',
the intent enrols (log: 'enrolled with 10.0.2.2:8519', enrollment.json
-rw-------), Diagnostics then reads 'enrolled: 10.0.2.2:8519', and the CA
reconstructed from that link is byte-identical to the machine's ca.pem and
validates the server over curl. Android offered the chooser between this
app and the Compose one, which is the intended behaviour.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:45:51 -04:00
irisandClaude Fable 5.1 22210a42f5 docs: review of 2026-09-07's work -- 5 defects, 7 risks, 3 tests that cannot fail
Read-only review of ba2afba..origin/rustify (the fling spline and Lsq2
velocity, list culling/clamp/anchor re-homing, nested masks, the headless
harness, insets/targetSdk, platform fonts, and the client-core log ring
with POST /client-log).

The three that matter most: the app's own log ring is installed at
LevelFilter::Debug while the same day added three ungated per-frame
`log::debug!` callsites, so the 2000-line ring wraps in under ten seconds
and the route built to get Iris's logs to her carries frame spam instead;
POST /client-log inherits the router's 32 MiB body limit with no
per-message or rate cap, so an authenticated client can fill the host's
disk through ai-server's runtime log; and every invariant added today is
a `debug_assert!` while the phone and the bench APK are both release
builds, so none of the new guards can fire where the defects were found.

Also: the input clock anchors on the first MotionEvent's own event_time,
so that event's historical samples date before the anchor and are
silently clamped onto one instant; the "before" fling velocity quoted in
four docs (11750 px/s) is not what velocity_reference.py prints (12250);
masks clip drawing but not hit-testing, so a straddling row is now
invisible above the list and still tappable through the header.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:38:43 -04:00
irisandClaude Fable 5.1 ade572973a enrolment carries the CA, and one store holds it on every platform
An APK built in this VM pins this VM's CA, so it can never reach the
host's ai-server -- which is exactly the iris Android client's situation
(cross-compiled here, run against the host). So ai-server now puts the CA
in every enrollment link it mints, base64url of its DER under the 'ca'
parameter wg-app-link just learned to add, and client_core parses it back
out as PEM. Nothing has to be built on the machine it talks to.

Refused rather than ignored where 'ca' does not decode: a link that named
a certificate and then pinned nothing is the one outcome nothing
downstream could notice.

EnrollmentStore moves out of desktop-app into client_core::config, since
the Android client needs the same file for the same reason and only the
directory differs by platform (AGENTS.md's sharing rule). desktop-app's
--ca becomes the override for a link that carried none.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:34:12 -04:00
irisandClaude Fable 5.1 9b27e858b5 docs/RUST.md: APK runtime logs in Dev Updater via an on-device ContentProvider (Iris, 2026-09-07); supersedes the ai-server client-log route
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:30:08 -04:00
irisandClaude Fable 5.1 7e4e26a335 iris: resolve fontique's Android monospace generic family ourselves
fontique 0.11.1's Android backend never resolves GenericFamily::Monospace
(mono=None in the startup diagnostic, RUST.md's 2026-09-07 "Platform
fonts" gap): DEFAULT_GENERIC_FAMILIES looks up "monospace" against
name_map before fonts.xml is parsed into it, and even after parsing,
AOSP's fonts.xml names it with a <family name="monospace"> element whose
<font> children the backend's own parser never reads (a TODO left in
place) -- so the name gets a FamilyId with no font data behind it, and
family_by_name("monospace") comes back empty too. Confirmed still present
on linebender/parley's main branch, so there is no newer release to bump
to.

TextData::patch_android_monospace (Android-only, called from
TextData::default) reads fonts.xml's own "monospace" declaration for the
font filename it names, then finds which of fontique's actually-scanned
families owns a font file with that name and registers it as the
Monospace generic directly -- the same authority Compose's
Typeface.MONOSPACE resolves through, without pinning an OEM-specific
family name. Verified on this checkout's emulator:
mono=Some("Droid Sans Mono") in the startup log, and a screenshot showing
the bench-fixture's code block and tool-card values in a visibly
monospaced face beside sans body/heading text. Desktop's fontconfig
backend is unaffected.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:25:27 -04:00
irisandClaude Fable 5.1 84a13e806b iris: a fling starts at Compose's velocity, which is a curve fit and not an average
Iris, from the phone on the 4274b8b build: "flinging now actually works
but is slower than Compose's immediately after releasing the flick (the
slow down seems correct)." The spline was already AOSP's; the initial
velocity was not.

`VelocityTracker` held per-frame pan deltas and answered their sum over
the sample span -- an average, which cannot tell an accelerating flick
from a steady drag. Ported from the `-sources.jar` of
androidx.compose.ui:ui-android:1.12.0 and
androidx.compose.foundation:foundation-android:1.12.0 (the versions the
Compose app builds against) rather than from memory, and the reading
corrected the plan twice:

  * The touch path is not `Strategy.Impulse`. `scrollable`/`draggable`
    release through the 2D `VelocityTracker`, which on Android is two
    `VelocityTracker1D(strategy = Lsq2)` over absolute positions -- a
    degree-2 least-squares fit differentiated at the newest sample.
    Impulse is reached only by `DifferentialVelocityTracker`, whose one
    caller is `NonTouchScrollingLogic`: wheel and trackpad.
  * There is no minimum fling velocity. `ViewConfiguration`'s 50dp/s is
    used only by `NestedScrollInteropConnection`; `DefaultFlingBehavior`
    skips `abs(v) <= 1f`, and says in its own comment that this is to
    dodge a NaN out of the spline. So `List::fling` caps at 8000dp/s
    against its own density and floors at 1px/s, and no threshold
    Compose does not have was added.

So the tracker holds positions rather than deltas (Lsq2 refuses
differential data in Compose too), 20 of them, with Compose's 100ms
horizon and 40ms stopped-gap; `DragGesture` feeds the raw window
coordinate along the drag axis at the press and every `Pan` frame.

`iris/benches/velocity_reference.py` is the independent transcription
the checked-in numbers come from, as `fling_spline_reference.py` is for
the curve. On `flick-120hz.touch`: 11750px/s before, 15250px/s after. On
an accelerating flick -- the shape a real finger makes, which that 16ms
recording is too short to show -- 1080 before, 2445 after. An average
also flings from a standstill (2533px/s where Compose says 0) and flings
from two points that describe no curve.

Negative control: reverting `velocity` to `total / span` fails exactly
seven tests, all of them about the estimator, and leaves the steady
drag, the tap, the selection release, the sixteen arbiter tests and the
rest of phone_screen.rs passing.

`iris drag release:` keeps its info line and gains a debug
`iris drag release samples:` with every held sample as `t_ms:position`,
so a flick that felt wrong on a phone with no logcat can be replayed at
layer 1 or pasted into the reference script.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:24:29 -04:00
irisandClaude Fable 5.1 452c44249f docs/RUST.md: queue -- logging landed; iris app enrolment replaces the build-time log destination; build-apk.sh traps
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:22:29 -04:00
irisandClaude Fable 5.1 238057ad5e docs: the phone-logging decision, how to use it, and two build-apk traps
DECISIONS.md gets the route and both rejected alternatives with what each
would have cost; RUST.md gets a "Phone logging" section with the build
command, where to read it on the phone, the end-to-end verification, and
the two rig traps that cost an hour -- Gradle's merged-native-libs cache
surviving build-apk.sh's `rm -rf jniLibs` (a --abi x86_64 APK packaged
arm64 and aborted with what reads exactly like a Vulkan fault), and the
648 MB debug bench APK that cannot be installed at all. IRIS.md gets the
client-core logging API with a before/after.

Queue item ticked.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:21:23 -04:00
iris 896c93a59a iris: drop bundled Noto Sans, match Compose's platform-font fonts
Iris's call: "remove the font for now; just match what compose does."
Removes the six embedded Noto Sans/Noto Sans Mono TTFs (3.6 MB) that
TextData::default used to register ahead of the platform's own fonts;
fontique's system font discovery was already on by default and now
runs unshadowed (Roboto/Roboto Flex on Android, fontconfig on the
desktop). .so -3,748,136 bytes (11,193,608 -> 7,445,472), matching the
estimate. Verified fallback still lands on visible tofu for CJK/emoji
rather than blank, and flagged (not fixed) a fontique Android backend
gap that leaves Monospace unresolved -- see RUST.md's "Platform fonts
(2026-09-07)" and DECISIONS.md/IRIS.md's dated entries.
2026-09-07 16:14:34 -04:00
iris 690161e5e9 docs: the transcript's edges were three faults, and what the rig found
IRIS_TODO's 2026-09-07 top-edge entry closed with the root cause of
each, the six layer-1 test names, and what was suspected and turned out
not to be it -- no culling test compared a row's top against the
viewport's, and 03c6be8's header duplicate is untouched and still open.
The later report's "you shouldn't be able to scroll below the bottom (or
above top)" is ticked with why the clamp is a correction measured from
the layout walk rather than a clamp inside the scroll setter: nothing at
the moment of a scroll knows where the content ends.

RUST.md gains the same account in "Where things stand", plus the three
things this said about the new test rig -- layer 1 found all of it in
seconds and the emulator was not used; layer 2 is where the missing clip
is visible, with the command; and an assertion that reads the wrong
thing hides the bug it is for, which is how a list resting 1398px past
its own first row passed a test about stopping at that row.

Also the last of the six tests, the bottom end of the clamp
(`scrolling_past_the_last_row_settles_on_it`) -- the same rule at the
edge the top-edge work had no reason to touch.
2026-09-07 16:07:37 -04:00
iris e922b73d7a iris: a transcript row is drawn if it overlaps the viewport, and clipped to it
Iris's phone, 2026-09-07, two screenshots of the transcript at its top
edge wrong in opposite directions: rows already scrolled past still
drawn, over the header bar (`version = "0.1.0"` behind "Run benchmark"),
and a blank band where the row straddling the edge should be. Three
faults, one rule -- `List::intersects_viewport`: a row is drawn if any
part of it is inside the list's own box, and nothing outside that box
reaches the screen.

1. **The walk drew everything between the anchor and the viewport.**
   `scroll` moves the anchor's offset and nothing else, so panning leaves
   the anchor's own row further and further outside the viewport, and
   every row in between was placed *and drawn* on every frame. Measured
   on the bench fixture: 8 scrolls of 3000px left 64 rows drawn for a
   2012px viewport, ~59 of them off screen. `place` now skips a row whose
   height is already known and whose box does not overlap; `rehome_anchor`
   moves the anchor onto a visible row each frame, without moving
   anything drawn, so the walk is O(visible) again whatever distance was
   travelled. `extents` holds only what is on screen, which is what
   `key_at` already claimed of it, asserted at the end of every draw.

2. **Nothing clipped the list.** A straddling row is drawn in full --
   that is the rule -- so the part above the list was on screen. The
   transcript's list is `.masked()` now (the mechanism `examples/
   message_list.rs` and the composer already use, and one that nests as
   of the previous commit), and `List::draw` asserts it has a mask rather
   than leaving that to each caller to remember.

3. **A fling past the first row stayed past it.** `tick_fling` stops a
   fling that has reached an end, wherever the spline's last step had put
   it: `fling_toward_the_start_stops_at_the_first_row` was leaving the
   first row 1398px below a 600px viewport -- a blank screen -- and its
   assertion could not see it, since `extents` then held off-screen rows
   too and `top >= -0.5` is satisfied by +1398. `clamp_to_content` gives
   the gap back from the ends the walk already placed. Only when the
   opposite end is not also in the viewport, so a list shorter than its
   viewport stays bottom-anchored as before.

Layer 1 of the test rig throughout (`transcript-fixture/tests/
top_edge.rs`, the real screen under a bench-app-shaped header): each of
the five fails on its own subject and no other -- culling on the row's
top instead of its bottom fails only `the_row_across_the_top_edge_is_
drawn`, the pre-fix walk fails only the two about what is placed,
dropping `.masked()` fails only `the_list_is_clipped_to_its_own_box`,
dropping the clamp fails only `scrolling_past_the_first_row_settles_on_
it`. The bottom edge and a list shorter than the viewport are the ends
none of this had a reason to touch and are covered too.
2026-09-07 16:05:31 -04:00
iris d507ae4c96 iris-core: masks nest instead of aborting, and a widget can ask to be drawn again
`Painter::set_mask` refused a widget any mask of its own once an
ancestor had set one -- `assertion failed: self.mask == MaskIdx::NONE`
-- so clipping was one level deep wherever it was used at all. That is
what stopped the transcript's `List` from being clipped to its own box:
its rows already use `.masked()` themselves (a code fence, a tool card's
one-line title), and giving the list one aborted on the first fence
drawn.

A mask now carries the mask it was set inside (`Mask::parent`) and the
fragment stage walks that chain, so a pixel has to be inside every mask
on it. Chained rather than intersected on the CPU because each mask
moves with its own widget: a fence inside a transcript row carries the
row's scroll and the list's box does not, and one region resolved when
the fence was last drawn gets the second of those wrong as soon as the
row is moved rather than redrawn -- which is every scroll frame. The
child holds one ref on its parent's slot, released where the child's own
slot is, so a chain cannot outlive what it points at. The old assert
survives as the case that is still wrong: the same widget setting two
masks, which since a mask now chains would be a clip loop.

Also `Painter::draw_again`, for a layout that can only discover a
correction to itself by laying out once -- `List::clamp_to_content`, in
the commit after this -- and `Painter::is_masked`, which is how a widget
that draws outside its own box can require something to be clipping it.
2026-09-07 16:05:13 -04:00
irisandClaude Fable 5.1 9ed01e2812 docs: phone report 2026-09-07 later -- overscroll, low initial fling velocity, input/timing report; queued
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:04:34 -04:00
irisandClaude Fable 5.1 5be9f1baac iris-android-app: keep the app's own log, put it in Copy report, upload it
`app_log` is the platform half: `android_logger` as the logger the ring
forwards to, and an optional destination baked in by `build.rs` from
`AI_APP_LOG_HOST`/`_PORT`/`_TOKEN` plus the pinned CA -- the same
build-time trust boundary the transcript config and the Compose APK's CA
already use, so no token is committed and an APK is good for the server
that built it. All three or none: two of the three would be a build with
nowhere to send its log and no way to say so.

`Copy report` now appends the ring to what goes on the clipboard (not to
the pane, which is on screen and would be buried) and flushes the
uploader first, so the lines are on the server by the time the message
describing them arrives. The Diagnostics pane gains two lines: how many
lines are held and when the last arrived, and what the uploader last did
-- "not tried yet", "failing -- <why>", and "no server configured" are
each their own wording, because "nothing is arriving" has three causes
that look identical otherwise.

Also: the re-emitted lines carry the target `ai_server::client_log`, not
a bare `client_log`. `RUST_LOG=ai_server=debug` -- the filter AGENTS.md
tells people to run with -- drops a bare target, so every line a phone
sent vanished with nothing saying so. Found by running it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 16:01:30 -04:00
irisandClaude Fable 5.1 977bdb9ee0 client-core: the app's own log ring, and POST /client-log to get it off a phone
Iris tests iris builds on a phone with no adb, and Android forbids one
app reading another's logcat, so a `log::info!` in the app can only reach
her if the app carries its own copy and sends it somewhere.

`client_core::log_ring` is that copy: a bounded ring (2000 lines / 256
KiB, whichever bites first) behind a `log::Log` backend that forwards to
whichever real logger the platform installed, so `logcat` and the desktop
terminal see exactly what they saw before. Reading does not consume --
the report and the uploader are two readers of one ring.

`client_core::log_upload` drains it into ai-server's new `POST
/client-log`, which re-emits each line into the server's own tracing
output. Dev Updater already shows that as ai-server's runtime log, so
nothing new is built there. A failed batch is retried from the same
cursor, and nothing in the upload path calls `log!` -- it would land in
the ring it is draining.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 15:56:20 -04:00
irisandClaude Fable 5.1 9cd1263080 docs/RUST.md: queue -- APK size done, the embedded-fonts question left for Iris
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 15:47:53 -04:00
iris 42af780639 iris android-app: strip+LTO+cgu1+opt-level=s halve libmain.so, no feature trim needed
Baseline had panic=abort only. Measured each setting in order (docs/RUST.md's
new "APK size (2026-09-07)" subsection has the full table and crate
breakdown): strip=true, lto="fat", codegen-units=1, opt-level="s" take
libmain.so from 18,546,488 to 11,193,608 bytes (-39.7%) and the release APK
from 20,678,956 to 13,326,076 bytes (-35.5%), arm64-v8a. opt-level="z" was
measured (another ~800KB) but not adopted without a frame-time check.

Investigated naga/wgpu backend features and tabs-ui/tabs-screen as trim
candidates; both are already fully eliminated by the linker on Android
(0 symbols in `llvm-nm` on the baseline .so), so no Cargo feature change
would shrink the binary -- left as documented findings rather than a diff.

Embedded Noto Sans fonts (3.6 MB) and the wgpu/naga/font-shaping stack
account for most of what remains vs. Compose, which borrows the platform's
own renderer and fonts for free; recorded honestly in the doc rather than
trimmed, since subsetting fonts or dropping a backend would change what
iris can render.
2026-09-07 15:46:40 -04:00
iris 4274b8b8d0 Merge remote-tracking branch 'origin/rustify' into worktree-agent-ace98b0bdaf33ffff
# Conflicts:
#	docs/IRIS.md
#	docs/RUST.md
2026-09-07 15:33:25 -04:00
irisandClaude Fable 5.1 73f956f8e0 iris: the fling curve was the identity function, and the keyboard was a targetSdk
Iris's 2026-09-07 phone report on ed04d4c: the resume glyph corruption is
fixed (item 4 closed with her evidence), flinging "seems to just be linear
velocity with an abrupt stop", and the keyboard still does not push
anything up. docs/RUST.md's new "The 2026-09-07 phone report" section has
the derivation and every number.

**The fling was arithmetically linear.** `android_fling_spline::
distance_fraction(t)` returned `t` for every `t`. Two halves of AOSP's
`SplineOverScroller` static initialiser had been transposed -- the
bisection solved the tension curve and the sample evaluated the P1/P2 one,
where AOSP does the opposite -- which made SPLINE_POSITION and SPLINE_TIME
identical; the lookup then bracketed `t` between SPLINE_TIME entries
instead of between even time steps, and the two cancelled to the identity.
Ported exactly now from OverScroller.java and androidx.compose.animation
1.12.0's SplineBasedDecay.kt, which agree line for line, as one table
indexed by even steps of time (AOSP's second table serves only
`adjustDuration`, which nothing here has, so it is deliberately not built
-- one array, one indexing rule). `FlingCalculator::velocity_at` is new
beside `position_at`, and `List::tick_fling` logs `iris fling tick:` with
the per-frame delta and speed.

Every existing test compared the calculator with itself -- monotonic,
signed, integrates to the closed form, deltas non-increasing -- and all of
them pass on a straight line. iris/benches/fling_spline_reference.py is an
independent hand transcription of both sources and supplies the numbers
now checked into `the_spline_matches_aosps_own_table` and
`a_flick_decelerates_the_way_aosp_says_it_does`;
`tick_fling_applies_shrinking_incremental_deltas` went from
"non-increasing" to "the last delta is under 80% of the first". Negative
control: with `sample` forced back to `t`, exactly those three fail.

Emulator (API 36, debug, force-gles): a released v=3750 decelerates
3746 -> 2624 -> 1834 -> 1144 -> 752 -> 449 -> 243 -> 83px/s over 32 frames
to t=0.664s; a flick into the end of the list stops there in one tick with
no overshoot; a tap 200ms into a fling ends it at 11 ticks.

**The keyboard: `targetSdk = 34`** in iris/android-app/app/build.gradle,
against compileSdk 37 and the Compose app's 37 -- and that app's keyboard
does push up on her phone. Below target 35 a window keeps the legacy
behaviour where adjustResize shrinks it for the IME, so
getInsets(ime()).bottom measures an already-shrunk window and is zero;
setDecorFitsSystemWindows(false) opts out of that and still takes on the
API 36 emulator here, which is why every test run passed. Now targetSdk 37.

That is a reading and not a measurement, so the other half is making the
phone able to answer it. MainActivity also registers a
WindowInsetsAnimation.Callback (onEnd re-reads getRootWindowInsets, so an
interrupted animation cannot freeze a value), which delivers the height
where only the animation path carries it and makes the push-up animate:
ime_bottom now arrives 509, 663, 833, 881, 883 instead of one jump.
`insets::Shared::updates` counts every dispatch and
`AndroidUiState::insets_report()` puts it in the Diagnostics pane --
screenshot-verified, `insets: dispatches=27 left=0 top=142 right=0
bottom=63 ime_bottom=0 ime_visible=false`. Iris has no logcat, and "the
listener never fired" and "it fired with a zero height" are otherwise the
same picture; dispatches=0 says so in words rather than showing defaults.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:44:01 -04:00
irisandClaude Fable 5.1 038f6a3832 docs: the test rig's layers 1 and 2, with their commands and their limits
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>
2026-09-07 12:39:53 -04:00
irisandClaude Fable 5.1 1121d7cc83 docs/LAYOUT.md: masks reference a drawn primitive instead of copying a shape, and hit-testing applies the shape (Iris, 2026-09-07)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:38:55 -04:00
irisandClaude Fable 5.1 232de0ec53 iris: a phone-shaped desktop window, driven by the same touch recordings
Layer 2 of docs/RUST.md's "Three test layers":

    ./run-headless.sh phone --phone --shot /tmp/p.png -- -p transcript-fixture

opens `transcript-fixture`'s screen -- the same fixture and the same
fold the headless tests and the Android bench use -- in a window at the
phone's own 1080x2424 and `content_scale` 2.55, and screenshots it. 15
seconds, warm. `--replay FILE` drives one of the `.touch` recordings
into it and writes `<shot>-before.png` too, so "the list moved" is two
pictures: the flick carries it back about seven turns of the fixture.

Two things this needed.

**The desktop backend now lays out in physical pixels with a density,
exactly as Android does** (`default::content_scale`, overridable with
`IRIS_SCALE`, which is how `--phone` hands it the phone's). It used to
divide winit's coordinates into a separate "logical" space, which left
`UiRenderState::resize` (physical, from `WindowEvent::Resized`) and the
window uniform (logical) disagreeing on any display whose scale factor
is not 1.0, and rasterised glyphs at one resolution to show them at
another. At 1.0 -- every display here -- the numbers are unchanged, and
the `tabs` screenshot is identical.

**`rig-input`'s `replay-touch`** puts a gesture on screen. This
machine's compositor has no pointer to move: sway runs on the headless
backend with no input devices, so `swaymsg seat - cursor press` reports
success and `swaymsg -t get_seats` shows `capabilities: 0`. wlroots 0.19
dropped `WLR_HEADLESS_INPUTS` and ydotool's uinput device would be
ignored by a compositor not reading libinput, so the virtual-pointer
protocol is what is left. It parses the *same* `TouchScript` the
harness does, so one recording drives both layers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:38:19 -04:00
irisandClaude Fable 5.1 e430880cde docs: phone report 2026-09-07, rows at the transcript's top edge culled early or drawn through the header
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:35:20 -04:00
irisandClaude Fable 5.1 a999bd106a docs: masks with a shape (LAYOUT.md, decided 2026-09-07) and the orchestrator queue in RUST.md
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>
2026-09-07 12:34:19 -04:00
irisandClaude Fable 5.1 6840edf61e iris-android-app: the bench's fixture half comes from transcript-fixture
The fixture bytes, the backlog/tail split and the fold into a screen
were `bench_client.rs`'s alone; they are `transcript-fixture`'s now, so
the Android bench, the headless harness and the phone-shaped desktop
window open one screen from one copy (AGENTS.md: nothing UI-shaped in a
platform crate). What stays here is the JNI half -- clipboard, battery,
IME, the report and the four phases.

Built with `cargo ndk -t arm64-v8a -P 29 build --features
"transcript-screen bench"`; the two warnings it prints (bench_jni's
unused overlay methods, the unused `tabs-ui` dependency under this
feature set) predate this change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:27:04 -04:00
irisandClaude Fable 5.1 333220196e iris: a headless in-process harness, and the bench fixture as a shared crate
Layer 1 of docs/RUST.md's "Three test layers": `iris::harness` opens a
real screen with no window, no compositor and no GPU, on an explicit
clock and a replayed touch stream -- a trivial `t_ms action x y` file,
so the batched 120Hz flick shape from Iris's phone report is
reproducible as a test. The emulator cannot produce that shape at all:
a `ui-trace` swipe is many evenly-spaced events, a finger is five
samples in 20ms.

`transcript-fixture` is the fixture-loading and fold-driving half of
`iris-android-app`'s `bench_client.rs`, moved out of the platform crate
so the harness, a desktop window and the Android bench open the same
screen from the same bytes (AGENTS.md's sharing rule).

Two supporting changes in iris itself, both about reading a clock that
was not handed in: `Fling::started_at` is now set on the first
`tick_fling` rather than at the release, so a driver running frames on
its own clock does not start every fling at the wall clock and advance
it on a different one; and `List::fling_velocity` exposes what the
release measured, which is where `Released(Some(v))` lands.

Four tests, each confirmed to fail without its subject: dropping
`animate(id)` from `Selection::drag` (the phone's own "fling does
nothing" defect) and reverting `started_at` each fail the flick test
alone; flinging on `Tapped` fails only the tap test; a 5s `LONG_PRESS`
fails only the selection test; a `set_bottom_inset` that ignores its
argument fails only the composer/IME test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:24:54 -04:00
irisandClaude Fable 5.1 7f4ea7e8fd docs/TODO.md: Compose app crash from Iris's phone log export, reversed AnnotatedString range in ToolInput.highlighted
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:22:47 -04:00
irisandClaude Fable 5.1 591128eef1 AGENTS.md: the phone app and the planned desktop app share widgets and styling; only screen layout differs
Iris, 2026-09-07. The second central design point beside the driver
rule, so a platform crate growing a widget or a colour reads as a
defect to move. docs/RUST.md carries the detail.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:13:54 -04:00
irisandClaude Fable 5.1 ba0f2ea93f docs: the 22:16 report reconciled with what was actually run
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>
2026-09-07 12:12:03 -04:00
irisandClaude Fable 5.1 ed04d4c735 iris: the keyboard reopens, the IME's height reaches the layout, and a fling actually moves
Items 1-3 of Iris's 22:16 phone report, plus the two defects that were
hiding behind item 1 and only became visible once the first one was
fixed. Emulator evidence and the numbers are in docs/RUST.md.

**Keyboard reopen.** `attr.rs`'s already-focused branch calls
`focus_gained` on a tap that stays inside `DRAG_SLOP` -- what Android's
own `EditText` does, `showSoftInput` being idempotent. Dismissing the IME
leaves the field focused, so the only branch that requested it never ran
again. Negative control run: without this one call the second tap leaves
`mInputShown=false`. Swipes across and out of the focused field still
summon nothing.

**IME height.** `MainActivity` sends `getInsets(ime()).bottom` and
`isVisible(ime())` as two values; the height used to be sent *as* the
boolean, so nothing had a number to pad by. `Insets`/`WindowInsets` carry
both, `bench_client` reads the boolean for its state machine and the
height for `Composer::set_bottom_inset`, and the list follows because it
is `rest(1)` in the same `Span`.

**Fling.** Three defects, in the order they were found:

1. `on_touch_event` read only each `MotionEvent`'s final position, so a
   batched 120Hz flick fed the tracker one sample and `velocity()`
   answered 0.0. Historical samples are replayed through the sensor pass
   now, `CursorState::time` carries each sample's own time (so a replay
   loop's speed cannot become the measured velocity -- the winit backend
   sets it too), the press is a sample as AOSP's own tracker does, and
   `iris drag release:` logs the decision for the phone's logcat.
2. Nothing advanced a fling between input events: `tick_fling`'s only
   caller was the benchmark's own loop, so the bench flung and a finger
   never did. iris has one animation mechanism now -- `Widget::tick`,
   `UiData::animate`/`tick_animations`, called by both backends before
   the draw and re-requesting a frame while it answers true.
3. With flings finally animating, one lasted 45 seconds: `List::fling`
   hardcoded density 1.0 against physical-pixel velocities, and
   `FlingCalculator`'s coefficient used the scroll friction where AOSP
   uses its 0.84 tuning constant -- 56x, inside an exponential. Emulator:
   1.62s for v=11064, against AOSP's own 1.586s.

**Two pre-existing faults found on the way.** `MOVE_CHAIN_LIMIT` was 16
and the composer's chain is 17, so every debug build aborted on a tap of
the composer and every release build silently drew and hit-tested that
subtree short; it is 64 in both the CPU walk and shader.wgsl, and the
assert prints the chain so a cycle and a deep tree can be told apart. And
`minSdk` is 29, since `getEventTimeNanos` is API 29 and a missing JNI
method is a crash rather than a degraded fling.

Every new invariant carries its guard: sample times non-decreasing in
`on_touch_event`, and tests confirmed to fail without their fix for the
press-seeded velocity, the animation registration and the AOSP
magnitudes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:11:55 -04:00
irisandClaude Fable 5.1 ba2afbaedb iris: a cleared glyph atlas must un-cache every RenderedText, not just empty itself
Iris's phone, 2026-09-06 22:16: after leaving the app and returning,
every glyph drawn *before* the resume came back as fragments of other
letters, while the diagnostics text drawn after it was perfect.

The renderer rebuild does force a full redraw -- `surface_changed` calls
`render.resize(...)`, which sets `UiRenderState::resized`, which makes
the next `update` take `redraw_all`. What survives that is one cache
further in: `TextView::render` returns its cached `RenderedText`
whenever the wrap width, buffer and attrs are unchanged, so
`TextData::place` is never reached, nothing is re-rasterised into the
fresh atlas, and the *previous* atlas's uv_min/uv_max/layer go straight
back to the GPU. Only text whose content changed after the resume
re-shapes -- exactly the split in the screenshot.

One mechanism rather than a per-holder invalidation path: `GlyphAtlas`
carries a `generation`, bumped by `clear`; a `RenderedText` records the
one it was placed against; and `TextView::render`'s cache key includes
it, so clearing the atlas makes every cached render un-reusable at once.
`Painter::glyphs` debug-asserts that a submitted quad's generation is
the live one, catching the fault at the submission instead of on screen.

Test `clearing_the_atlas_re_renders_cached_text_instead_of_reusing_it`
(iris/src/widget/text/mod.rs): draw, clear the atlas, resize, draw
again, and assert the atlas holds the same glyph count. Confirmed to
fail without the cache-key line -- it trips the new debug_assert with
"glyphs placed against atlas generation 0 submitted against 1".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 23:22:40 -04:00
iris 10267dec27 Merge branch 'worktree-agent-a673ba12761c025d9' into rustify 2026-09-06 23:20:30 -04:00
irisandClaude Fable 5.1 7e7cbb5402 Tool-call cards and grouping, with the state a result never arrived in
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>
2026-09-06 22:49:51 -04:00
iris a200ddbddd docs/IRIS_TODO.md: Iris's 22:16 phone report on the 20303e0 build, four open items with the reading of each 2026-09-06 22:31:07 -04:00
iris b332873894 Merge remote-tracking branch 'origin/rustify' into worktree-agent-a673ba12761c025d9 2026-09-06 21:33:28 -04:00
irisandClaude Fable 5.1 a4809b3026 WIP: tool-call cards and grouping (P1b)
`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>
2026-09-06 21:33:24 -04:00
iris 1ad2f9ec6e docs/RUST.md: phone delivery is a push to ai-app-bench, not ~/host/bench 2026-09-06 20:04:26 -04:00
iris 33e8ab83a2 docs/RUST.md: the two 2026-09-06 fixes under P1a, with the emulator's first legible screenshot 2026-09-06 19:59:57 -04:00
iris f5b88932b4 iris: a widget's move slot has one owner -- move_applied + repositioned
`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.
2026-09-06 19:59:39 -04:00
irisandClaude Fable 5.1 9079276ec8 A tool call can say it failed, and what it is for, without a renderer
P1b's pure half (docs/RUST.md). Three pieces, all testable with no
widget in sight:

- `event_model::Event::ToolEnd` gains `is_error`, read from the CLI's own
  `tool_result` field by both the live translator and the import replay
  (`import::tool_result_is_error`, one reader so the two cannot disagree
  about the same conversation). Without it a result is all a card has,
  and a broken call draws exactly as confidently as one that worked --
  the missing state, not a wrong one. `#[serde(default)]`, so an older
  transcript reads back as "not reported to have failed".
- `client_core::transcript_fold::ToolState`: Running, Deciding,
  Succeeded, Failed, NoResult. The pair it exists for is the last two
  against Succeeded-with-empty-output -- a call that printed nothing and
  a call whose result never arrived leave the same empty string, and only
  the session's status separates "still going" from "nobody found out".
- `client_core::tool_summary::parse_tool_input` and
  `client_core::durations`: `ToolInput.kt`'s subject/description/timeout
  split and `Durations.kt`'s span formatting, ported with their tests.

The echo driver's three-call run now has a failing middle call, so the
failed appearance is reachable from `ui-sandbox.sh` at all.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 19:46:21 -04:00
iris 3cb18ac5c2 iris: a one-layer glyph atlas is a GL_TEXTURE_2D, so every glyph drew as a box
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).
2026-09-06 19:41:40 -04:00
irisandClaude Fable 5.1 69525bd131 iris: a Rect is not size-independent, and P1a's block appearance verified
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(&region)` 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>
2026-09-06 19:30:39 -04:00