From 4ccfda6b8ea38f24cbc3ba78d701c6e96349c6b6 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Wed, 9 Sep 2026 00:16:24 -0400 Subject: [PATCH] Delete the decisions and design logs; scripts, rigs and xtask off the root Iris: "remove both decisions and iris.md. I've decided to instead make decisions when planning with agents rather than after they do things, and they're both too long for me to wanna read, + don't cover all the decisions I'll wanna make about the code anyways. I'll just naturally run into things for now. Todo is important though." So docs/DECISIONS.md (850 lines) and docs/IRIS.md (1,986) are gone, and AGENTS.md now says not to start another: raise a choice while planning it with her, otherwise decide it and put the reasoning at the code it governs. The TODO lists stay. docs/SUBAGENTS_DECISIONS.md went with them -- same artefact, same reasoning, and she did not name it, so its six decisions were folded into docs/SUBAGENTS.md rather than deleted. Deleting the logs left ~30 citations dangling in code comments and docs. Each states its reason inline and cited the file only for provenance, so they now read "decided 2026-09-07" or name the module doc that carries the reasoning. The root had six things that were not a program or a document. Moved, per "I only meant top level sh files": run-tests.sh, test-wg-tunnel.sh, wg-setup-host.sh -> scripts/ rigs/ -> scripts/rigs/ xtask/ -> scripts/xtask/ A project's own scripts stayed with the project: app/*.sh, app-rust/*.sh, iris/*.sh and server/enroll-link.sh did not move. `target/` at the root is deleted and cannot come back: there was never a workspace there, and the 29 MB was only xtask's scratch space, now in scripts/xtask/target/. `cargo xtask apk` still runs from the repo root and now publishes to scripts/build/outputs/apk// -- one directory deep, because that is what Dev Updater's `*/build/outputs/apk/*/*.apk` discovery pattern needs, and scripts/xtask/build would have been two. Verified: ./scripts/run-tests.sh and `cd iris && cargo test` green, clippy and fmt clean everywhere, `cargo xtask apk debug --abi x86_64` builds and signs an APK carrying lib/x86_64/libai_app.so at the new publish path, and the repo root is now eleven entries with no build output among them. Co-Authored-By: Claude Opus 5 --- .cargo/config.toml | 10 +- .dev-updater.ron | 8 +- .gitignore | 15 +- AGENTS.md | 57 +- app-rust/examples/phone.rs | 2 +- app-rust/src/android/app_log.rs | 2 +- app-rust/src/bin_desktop.rs | 2 +- app-rust/src/client/markdown_blocks.rs | 2 +- app-rust/src/ui/markdown.rs | 4 +- app-rust/src/ui/mod.rs | 10 +- app-rust/src/ui/row.rs | 5 +- app-rust/src/ui/selection.rs | 6 +- .../kotlin/com/example/aiapp/BenchFixture.kt | 2 +- docs/CLIENT_CORE.md | 4 +- docs/DECISIONS.md | 850 ------- docs/IRIS.md | 1986 ----------------- docs/IRIS_TODO.md | 7 +- docs/LAYOUT.md | 4 +- docs/RUST.md | 25 +- docs/SCROLL.md | 2 +- docs/SUBAGENTS.md | 42 +- docs/SUBAGENTS_DECISIONS.md | 44 - docs/TEXTURES.md | 8 +- iris/core/src/icon.rs | 2 +- iris/core/src/primitive/text.rs | 6 +- iris/examples/message_list.rs | 2 +- iris/src/sense.rs | 2 +- iris/tests/mask_sdf.rs | 2 +- {rigs => scripts/rigs}/gpu-probe/.gitignore | 0 {rigs => scripts/rigs}/gpu-probe/Cargo.lock | 0 {rigs => scripts/rigs}/gpu-probe/Cargo.toml | 0 .../rigs}/gpu-probe/src/bin/teardown.rs | 0 {rigs => scripts/rigs}/gpu-probe/src/main.rs | 0 {rigs => scripts/rigs}/gpu-probe/src/vk.rs | 0 .../rigs}/virtgpu-probe/virtgpu-probe.c | 0 run-tests.sh => scripts/run-tests.sh | 4 +- .../test-wg-tunnel.sh | 9 +- wg-setup-host.sh => scripts/wg-setup-host.sh | 6 +- {xtask => scripts/xtask}/Cargo.lock | 0 {xtask => scripts/xtask}/Cargo.toml | 0 {xtask => scripts/xtask}/src/apk.rs | 62 +- {xtask => scripts/xtask}/src/keystore.rs | 0 {xtask => scripts/xtask}/src/main.rs | 0 {xtask => scripts/xtask}/src/sdk.rs | 0 44 files changed, 198 insertions(+), 2994 deletions(-) delete mode 100644 docs/DECISIONS.md delete mode 100644 docs/IRIS.md delete mode 100644 docs/SUBAGENTS_DECISIONS.md rename {rigs => scripts/rigs}/gpu-probe/.gitignore (100%) rename {rigs => scripts/rigs}/gpu-probe/Cargo.lock (100%) rename {rigs => scripts/rigs}/gpu-probe/Cargo.toml (100%) rename {rigs => scripts/rigs}/gpu-probe/src/bin/teardown.rs (100%) rename {rigs => scripts/rigs}/gpu-probe/src/main.rs (100%) rename {rigs => scripts/rigs}/gpu-probe/src/vk.rs (100%) rename {rigs => scripts/rigs}/virtgpu-probe/virtgpu-probe.c (100%) rename run-tests.sh => scripts/run-tests.sh (90%) rename test-wg-tunnel.sh => scripts/test-wg-tunnel.sh (93%) rename wg-setup-host.sh => scripts/wg-setup-host.sh (95%) rename {xtask => scripts/xtask}/Cargo.lock (100%) rename {xtask => scripts/xtask}/Cargo.toml (100%) rename {xtask => scripts/xtask}/src/apk.rs (89%) rename {xtask => scripts/xtask}/src/keystore.rs (100%) rename {xtask => scripts/xtask}/src/main.rs (100%) rename {xtask => scripts/xtask}/src/sdk.rs (100%) diff --git a/.cargo/config.toml b/.cargo/config.toml index f2422b5..1ed9742 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,6 +1,10 @@ # xtask convention (https://github.com/matklad/cargo-xtask), without folding # every crate in this repo into one workspace -- they are deliberately -# independent (see run-tests.sh, which cds into each). `cargo xtask apk` -# from the repo root runs xtask/src/main.rs directly. +# independent (see scripts/run-tests.sh, which cds into each). +# +# `cargo xtask apk` **from the repo root** runs scripts/xtask/src/main.rs. +# The manifest path is relative to the working directory cargo is run from, +# so the root is where it works; this file is found from any directory +# inside the checkout, but the path inside it is not. [alias] -xtask = "run --quiet --manifest-path xtask/Cargo.toml --" +xtask = "run --quiet --manifest-path scripts/xtask/Cargo.toml --" diff --git a/.dev-updater.ron b/.dev-updater.ron index 36fdeed..bc42ad5 100644 --- a/.dev-updater.ron +++ b/.dev-updater.ron @@ -64,9 +64,11 @@ components: [ // defaults to this checkout's root, which both the `cargo xtask` // alias (`.cargo/config.toml`, resolved relative to the working // directory cargo is run from) and `cargo xtask apk`'s own publishing - // step (`xtask/build/outputs/apk//*.apk`, matching discover.rs's - // `*/build/outputs/apk/*/*.apk` pattern -- see apk.rs's module doc) - // both need. + // step (`scripts/build/outputs/apk//*.apk`, matching + // discover.rs's `*/build/outputs/apk/*/*.apk` pattern -- see apk.rs's + // module doc) both need. The publish directory is `scripts/build` + // rather than `scripts/xtask/build` for exactly that reason: the + // pattern is one directory deep, and the tool moved two on 2026-09-09. Apk( name: "shell", modes: ["release", "debug"], diff --git a/.gitignore b/.gitignore index 9ca82fc..715da09 100644 --- a/.gitignore +++ b/.gitignore @@ -9,8 +9,7 @@ local.properties .DS_Store server/target/ event-model/target/ -client-core/target/ -android-shell/target/ +app-rust/target/ # E3's native library, built by cargo-ndk straight into the Gradle module # (RUST.md) -- an artifact, like server/target/ above, not source. @@ -32,11 +31,9 @@ sessions/ # iris, the in-house UI library, is vendored at iris/ and built by cargo. iris/target/ -iris/android-app/target/ -# E5's packaging xtask (RUST.md). `build/` above already covers -# xtask/build/outputs/apk (the published APK, see apk.rs's module doc). -# The repo root has no Cargo workspace, so this is xtask's own -# intermediate working files (target/xtask/apk/...), not a shared one. -xtask/target/ -/target/ +# The packaging xtask and the GPU rigs, both under scripts/. `build/` +# above already covers scripts/build/outputs/apk, where `cargo xtask apk` +# publishes for Dev Updater to find. +scripts/xtask/target/ +scripts/rigs/gpu-probe/target/ diff --git a/AGENTS.md b/AGENTS.md index 441bba2..c9724ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,6 +72,24 @@ Module-by-module intent is in `docs/PLAN.md`'s "Backend layout". (sessions, import, models, setups); `Api.kt`/`EventStream.kt` the REST + SSE clients; `Events.kt` the event model mirror; `ServerConfig.kt` settings and the Keystore-sealed token. +- `scripts/` — everything at the root that was neither a program nor a + document: the three repo-wide shell scripts (`run-tests.sh`, + `test-wg-tunnel.sh`, `wg-setup-host.sh`), `rigs/` (the `gpu-probe` and + `virtgpu-probe` device probes), and `xtask/`. **A project's own scripts + stay with the project** — `app/*.sh`, `app-rust/*.sh`, `iris/*.sh` and + `server/enroll-link.sh` did not move (Iris, 2026-09-09: "I only meant + top level sh files"). + `scripts/xtask/` is the [cargo-xtask](https://github.com/matklad/cargo-xtask) + convention: an ordinary Rust binary that does build work a shell script + would otherwise do, run as `cargo xtask apk` **from the repo root** + (`.cargo/config.toml`'s alias, whose `--manifest-path` is relative to + the working directory). It packages `app/shellApp` without Gradle + driving it — `cargo ndk` → `javac`/`d8` → `aapt2` → `zipalign` → + `apksigner` — and publishes to `scripts/build/outputs/apk//`, + which is where Dev Updater looks. There is deliberately **no `target/` + at the repo root** any more: there is no workspace there, and what used + to be one was only xtask's own scratch space, now in + `scripts/xtask/target/`. - `wg-app-link/` — a **git submodule** shared with dev-updater: the pinned CA and leaf (`certs`), QR enrollment and the bearer token (`enroll`), wg0 binding and the certificate's SANs (`netif`), owner-only files (`private`), @@ -89,19 +107,16 @@ Module-by-module intent is in `docs/PLAN.md`'s "Backend layout". Read it before touching `TranscriptCache.kt`, `TranscriptSource.kt`, or the opening and stream effects in `SessionScreen.kt`. - `docs/TODO.md` — the working list. - - `docs/SUBAGENTS.md` and `docs/SUBAGENTS_DECISIONS.md` — a session's - subagents (the wire shape, the phone's view) and the choices behind - them still awaiting review. + - `docs/SUBAGENTS.md` — a session's subagents: the wire shape, the + phone's view, and the choices behind the shape. - `docs/RUST.md` — the plan for moving the app to Rust (on the `rustify` branch of the `ai-app-2` clone): what has to be reproduced, the framework decision, and the ordered experiments with their pass conditions. Read it before touching anything under that branch. - - `docs/IRIS.md`, `docs/IRIS_TODO.md`, `docs/DECISIONS.md`, - `docs/LAYOUT.md`, `docs/TEXTURES.md`, `docs/CLIENT_CORE.md` — iris's - own build log (**any major addition or design decision, not only - public API** -- Iris, 2026-09-08), working list, decisions log, - layout/render design, texture-atlas design, and the design of - `app-rust`'s `client` module, respectively. + - `docs/IRIS_TODO.md`, `docs/LAYOUT.md`, `docs/TEXTURES.md`, + `docs/CLIENT_CORE.md` — iris's open working list, its layout/render + design, its texture-atlas design, and the design of `app-rust`'s + `client` module, respectively. **These documents are pruned as the work lands, not appended to forever** (Iris, 2026-09-08: *"remove everything that's already done and @@ -112,6 +127,18 @@ Module-by-module intent is in `docs/PLAN.md`'s "Backend layout". survives is what cannot be cheaply re-derived — measurements, dead ends and failed hypotheses, invariants and their reasons, and the design of what exists now rather than the route to it. + + **There is no decisions log and no design log, and one should not be + started.** `docs/DECISIONS.md` and `docs/IRIS.md` were deleted on + 2026-09-09 at Iris's instruction: *"I've decided to instead make + decisions when planning with agents rather than after they do things, + and they're both too long for me to wanna read, + don't cover all the + decisions I'll wanna make about the code anyways. I'll just naturally + run into things for now."* So raise a choice **while planning it with + her**, when the direction is still cheap to change; otherwise decide it, + put the reasoning at the code it governs, and carry on. TODO lists are + still wanted — a list of open work is useful, a list of finished work + is not. - `docs/SCROLL.md` — how anything in iris scrolls: one `ScrollController` holds the position, the gesture, the fling and the pin, and the two widgets that scroll (`ScrollArea`, `LazySpan`) own @@ -151,13 +178,13 @@ test, `every_icon_is_in_the_bundled_font`), same Mono face, same Material Design family so an icon means the same thing in both apps. Its subset is separate rather than shared because subsetting only what one app draws is the point. This is the **only** font iris bundles — body and monospace -text come from the platform (docs/DECISIONS.md, 2026-09-07), and an icon +text come from the platform (decided 2026-09-07), and an icon is the opposite case: a small closed set of codepoints no system font is guaranteed to have. ## Checking your work -- **Rust**: `./run-tests.sh` from the repo root runs `event-model`, +- **Rust**: `./scripts/run-tests.sh` from the repo root runs `event-model`, `server/` and `app-rust/`; `cd iris && cargo test` runs the framework's own suite, which is slower and not about this product. Each workspace also gets `cargo clippy --all-targets` and `cargo fmt`. The build stays @@ -226,7 +253,7 @@ guaranteed to have. whether it was continued or reset (`stream backlog:`). That is the only place "how far had this phone fallen behind" is answerable — the app sees a window arrive and cannot tell. -- **`./test-wg-tunnel.sh up|test|down`** builds a real tunnel between two +- **`./scripts/test-wg-tunnel.sh up|test|down`** builds a real tunnel between two network namespaces inside one machine and drives the server through it — a genuine handshake against 10.66.0.1 with pinned TLS, no router or phone involved. That is how to verify the wg0-only posture. @@ -236,7 +263,7 @@ guaranteed to have. Each exists because something was invisible without it. - **The `bench` build type and `app/bench-fixture/`** exist for P0 (RUST.md - and DECISIONS.md's 2026-09-05 entries), the phone benchmark gate Iris + and the 2026-09-05 decisions), the phone benchmark gate Iris asked for before porting continues: a deterministic, checked-in synthetic transcript (`app/bench-fixture/generate.py`, never a real one) that both this app and iris open with no server, so a frame-time comparison @@ -363,7 +390,7 @@ Each exists because something was invisible without it. lifecycle and one verification run before a build goes to the phone -- not for iterating on layout. - **The emulator is a GLES rig, deliberately** (Iris, 2026-09-08; - docs/DECISIONS.md). Its guest has no hardware Vulkan -- only SwiftShader + docs/RUST.md). Its guest has no hardware Vulkan -- only SwiftShader in software -- while its GLES *is* the host's real GPU through virgl at ES 3.1, so an ordinary build's runtime fallback lands there by itself and nothing should pass `force-gles` to arrange it. The Vulkan path is @@ -444,7 +471,7 @@ means here: - **`ai-server` belongs on the host in production.** That is where the LAN address the phone can reach is, and where WireGuard terminates. - `wg-setup-host.sh` sets that up (keys, `wg0.conf`, the phone's QR); run it + `scripts/wg-setup-host.sh` sets that up (keys, `wg0.conf`, the phone's QR); run it there with `sudo WG_ENDPOINT=`. - **The tunnel and the real phone can never terminate in the VM**, because nothing outside can open a connection into it. Phone bring-up is host work. diff --git a/app-rust/examples/phone.rs b/app-rust/examples/phone.rs index 65a725a..8781926 100644 --- a/app-rust/examples/phone.rs +++ b/app-rust/examples/phone.rs @@ -19,7 +19,7 @@ //! instead, one character per 100ms: laying the composer out from //! scratch and growing one already on screen are different cases, and //! only the second reproduced the caret landing in the bar's padding -//! (IRIS.md, 2026-09-08). +//! (decided 2026-09-08). //! //! No server: `transcript-fixture` embeds the transcript. Colour, //! spacing, type and anything a person has to *see* is answered here; diff --git a/app-rust/src/android/app_log.rs b/app-rust/src/android/app_log.rs index ec62da3..213aec0 100644 --- a/app-rust/src/android/app_log.rs +++ b/app-rust/src/android/app_log.rs @@ -11,7 +11,7 @@ //! another's `logcat`. Nothing outside this process can recover what it //! wrote, so the process keeps a copy -- and hands it to Dev Updater on //! the same phone through `devlog`'s `ContentProvider`. See -//! `docs/DECISIONS.md`, 2026-09-07. +//! decided 2026-09-07. use crate::client::log_ring::{self, LogRing}; diff --git a/app-rust/src/bin_desktop.rs b/app-rust/src/bin_desktop.rs index 6dbef9d..9bb869f 100644 --- a/app-rust/src/bin_desktop.rs +++ b/app-rust/src/bin_desktop.rs @@ -10,7 +10,7 @@ //! desktop-app --ca /path/to/ca.pem # a link that carries no CA //! //! `--link` is the same text `app/ui-sandbox.sh`'s banner prints and a -//! phone would scan as a QR (DECISIONS.md, 2026-09-05) -- pasted rather +//! phone would scan as a QR (decided 2026-09-05) -- pasted rather //! than scanned, since a desktop has no camera to assume. It is parsed and //! saved once; later runs read it back and `--link` is only needed again //! to enrol against a different server. diff --git a/app-rust/src/client/markdown_blocks.rs b/app-rust/src/client/markdown_blocks.rs index 9df6596..b743435 100644 --- a/app-rust/src/client/markdown_blocks.rs +++ b/app-rust/src/client/markdown_blocks.rs @@ -8,7 +8,7 @@ //! the stream phase at p50 18.2ms against Compose's 13.4ms for exactly //! that reason (docs/IRIS_TODO.md). A row is a column of one widget per //! block now, and a delta that lands in the last block leaves every -//! earlier block's layout alone. `docs/DECISIONS.md`'s 2026-09-06 entry has +//! earlier block's layout alone. the 2026-09-06 decision has //! what that rejected and why the split lives here rather than in the UI //! crate: `docs/CLIENT_CORE.md` already wanted a block model for P1, and //! keeping it here means iris stays a text renderer that knows nothing diff --git a/app-rust/src/ui/markdown.rs b/app-rust/src/ui/markdown.rs index 6f80fcb..7b52a9a 100644 --- a/app-rust/src/ui/markdown.rs +++ b/app-rust/src/ui/markdown.rs @@ -18,7 +18,7 @@ //! a quote is *text behind a coloured bar*. Everything else markdown can //! say is expressed in the spans, which cost no widgets and no layout //! nodes. `app/.../Markdown.kt`'s component table is the reference for the -//! sizes and colours; docs/DECISIONS.md's 2026-09-06 entry records where +//! sizes and colours; the 2026-09-06 decision records where //! this deliberately differs. //! //! **What this deliberately does not attempt**, each for a reason recorded @@ -450,7 +450,7 @@ const TABLE_MAX_COL: usize = 28; /// and pixel widths the same thing, so padding each cell to its column's /// width *is* alignment, the column widths are measured from the cells, /// and the block reuses `BlockFrame::Verbatim`'s sideways pan for a table -/// too wide to fit. docs/DECISIONS.md, 2026-09-06, has what this trades. +/// too wide to fit. decided 2026-09-06, has what this trades. pub fn table_text(src: &str) -> Rendered { let rows = table_cells(src); if rows.is_empty() { diff --git a/app-rust/src/ui/mod.rs b/app-rust/src/ui/mod.rs index b51a9d9..78f7ab8 100644 --- a/app-rust/src/ui/mod.rs +++ b/app-rust/src/ui/mod.rs @@ -37,8 +37,8 @@ //! every frame it was pressed. `row.rs` now routes every row's drag //! through one shared `iris::sense::DragArbiter` //! (`Selection::drag`, `selection.rs`), which decides pan vs. select the -//! way Android itself does -- see `DragArbiter`'s own doc and -//! `DECISIONS.md` for the exact rule. `LazySpan` scrolls correctly when +//! way Android itself does -- `DragArbiter`'s own doc has the exact +//! rule. `LazySpan` scrolls correctly when //! driven programmatically (I3's benchmark), via the mouse wheel (wired //! below, `CursorSense::Scroll`), and now via a touch pan starting on a //! row's own text too. @@ -275,8 +275,8 @@ impl TranscriptScreen { // delta path: the row is a column of one widget per // markdown block, so a delta that lands in the last block // is one `set_with_spans` and the earlier blocks keep - // their layouts (`row::RowBlocks::apply_delta`, and - // docs/DECISIONS.md for why the row is shaped that way). + // their layouts (`row::RowBlocks::apply_delta`, whose doc + // says why the row is shaped that way). let old_key = row::row_key(&old_rows[common].key()); let new_key = row::row_key(&new_rows[common].key()); if new_key == old_key && self.apply_tail_delta(rsc, new_key, &new_rows[common]) { @@ -764,7 +764,7 @@ mod apply_tests { (draws, shapes) } - /// The pass condition for docs/DECISIONS.md's per-block row: a delta + /// The pass condition for the per-block row: a delta /// costs the **last block**, not the message. A 3,000-character reply /// has a hundred paragraphs already laid out; redrawing one delta into it /// must cost exactly what the same delta costs in a one-paragraph diff --git a/app-rust/src/ui/row.rs b/app-rust/src/ui/row.rs index 3ee4744..8d0b103 100644 --- a/app-rust/src/ui/row.rs +++ b/app-rust/src/ui/row.rs @@ -11,8 +11,7 @@ //! meant a streamed delta re-shaped every paragraph of a long reply //! through parley again -- the stream phase was the one place iris trailed //! Compose on Iris's phone. [`RowBlocks::apply_delta`] is the other half -//! of the fix; docs/DECISIONS.md's entry has what the alternative shapes -//! were and why this one. +//! of the fix. //! //! A `TranscriptRow::Tools` (a run of adjacent tool calls, grouped by //! `crate::client::transcript_fold::group_tool_runs`) is the row that proves @@ -127,7 +126,7 @@ fn tool_call_markdown(tool: &str, input: &str, output: &str) -> String { /// The per-block text widgets of one row, kept by `TranscriptScreen` for /// the row a reply is streaming into, so a delta can replace the block it /// lands in instead of re-shaping the whole message -/// (docs/DECISIONS.md, 2026-09-06). Nothing else needs it: a row that is +/// (decided 2026-09-06). Nothing else needs it: a row that is /// not the tail never changes. pub struct RowBlocks { /// What each field was built from, in order -- compared against a diff --git a/app-rust/src/ui/selection.rs b/app-rust/src/ui/selection.rs index 0f98177..8647f7b 100644 --- a/app-rust/src/ui/selection.rs +++ b/app-rust/src/ui/selection.rs @@ -35,8 +35,8 @@ use std::{collections::BTreeMap, time::Instant}; /// What this selects between: a row's `RowKey` and the index of one /// markdown **block** inside it. A row is a column of one text widget per -/// block since 2026-09-06 (`crate::client::markdown_blocks`, and -/// docs/DECISIONS.md for why), so the block, not the row, is the unit -- +/// block since 2026-09-06 (`crate::client::markdown_blocks`), so the +/// block, not the row, is the unit -- /// `(row, block)` compares lexicographically, which is reading order for /// both levels, so every range query below is unchanged. pub type SelKey = (RowKey, u32); @@ -49,7 +49,7 @@ pub struct Selection { /// pan wanting the same touch gesture). See `drag` below, and /// `iris::sense::DragGesture`'s own doc for the arbitration, velocity /// tracking and pointer-capture mechanics this no longer owns itself - /// -- Iris's 2026-09-06 ask (`IRIS.md`) that a drag's *mechanics* live + /// -- Iris's 2026-09-06 ask that a drag's *mechanics* live /// in iris's default input layer, with only the pan-vs-select /// *decision* staying here. gesture: DragGesture, diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/BenchFixture.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/BenchFixture.kt index c1802f1..35778b3 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/BenchFixture.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/BenchFixture.kt @@ -4,7 +4,7 @@ import android.content.Context import java.util.concurrent.CopyOnWriteArrayList /** - * P0's benchmark gate (see docs/RUST.md and docs/DECISIONS.md's 2026-09-05 entry): an in-process + * P0's benchmark gate (see docs/RUST.md and the 2026-09-05 decision): an in-process * fake of the backend, so the `bench` build type can drive a real session screen -- the real * [TranscriptSource], the real fold, the real paging -- with no server and no network permission. * diff --git a/docs/CLIENT_CORE.md b/docs/CLIENT_CORE.md index 9ef0686..6f41eaa 100644 --- a/docs/CLIENT_CORE.md +++ b/docs/CLIENT_CORE.md @@ -133,7 +133,7 @@ the exact `aiapp://enroll?host=H&port=P&token=T` deep link `wg-app-link`'s `enroll` mints and `ServerConfig.kt`'s `handleEnrollment` parses on the phone -- so any Rust client enrols from the same text a phone would scan as a QR, with no second format invented for it (RUST.md's -E4, DECISIONS.md 2026-09-05). Deliberately does not decide where it is +E4, decided 2026-09-05). Deliberately does not decide where it is persisted or under what file permissions -- a phone seals its token in the Android Keystore, `src/desktop/config.rs` writes it to `$XDG_CONFIG_HOME/ai-app-desktop/enrollment.json` at 0600 -- since that is @@ -214,7 +214,7 @@ does not repeat it again by hand. ## Verifying -`./run-tests.sh` from the repo root runs `event-model`, `server` and +`./scripts/run-tests.sh` from the repo root runs `event-model`, `server` and `app-rust` in that order (each `cargo test`, forwarding arguments the same way it always has). From `app-rust/` directly: `cargo test`, `cargo clippy --all-targets`, `cargo fmt` -- all clean as of 2026-09-08, 229 tests across diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md deleted file mode 100644 index bc9abcd..0000000 --- a/docs/DECISIONS.md +++ /dev/null @@ -1,850 +0,0 @@ -# Decisions taken for Iris to review - -Short list of design choices made by the design agent without asking, so -they can be judged and reversed later. Detail lives in RUST.md (and IRIS.md -for iris API changes); this file is only the summary. Newest first. Items -marked **DEFERRED** are ones the agent chose not to decide alone. - -**Paths in older entries are pre-2026-09-08.** The port's crates became -one crate on that date -- `client-core` -> `app-rust`'s `src/client`, -`transcript-ui` -> `src/ui`, `transcript-fixture` -> `src/ui/fixture.rs` -and `tests/`, `desktop-app` -> `src/desktop`, `android-app` -> -`src/android`, `android-shell` -> `src/shell`. Entries are left as they -were written rather than rewritten, since each is a dated record; -`docs/RUST.md`'s "One app crate" is the mapping. - -## 2026-09-08 (the port is one crate, and iris is framework-only) - -Asked for by Iris directly, so the shape rather than the fact is what is -open to review. `app-rust/` is one crate, `ai-app`, with `client`, `ui`, -`desktop`, `android` and `shell` as modules; `iris/` holds only `core`, -`macro`, `iris`, `tabs-ui` and `rig-input`. docs/RUST.md's "One app crate" -has the table and the reason each old split did or did not survive. - -Three calls made inside that, none of which she named: - -1. **`event-model` stayed at the repo root** rather than moving into - `app-rust/` -- her choice when asked, since `server/` depends on it too. -2. **The Android application id and Java package were left alone** - (`dev.iris.android.demo`, label "iris android-view demo"), though both - now name the wrong thing. Renaming them makes the next install - side-by-side rather than an upgrade on her phone, and changes the - `DevLogProvider` authority Dev Updater reads. Say the word and it is a - small change. -3. **The `bench` fixture is behind a `fixture` feature that is on by - default**, with `build-apk.sh` passing `--no-default-features` so an APK - carries the 1.9 MB fixture only when it asked for `bench`. The - alternative -- default off -- would have made `cargo test` silently skip - the six harness suites, which is the worse failure. - -## 2026-09-08 (last: scrolling moves out of the list) - -Agreed with Iris in the exchange that followed, so most of this is her -call rather than mine. IRIS.md has the account. What I decided along the -way, and would flag for reversal: - -- **A third `Widget` method, `scroll_offset`**, beyond the two we agreed. - `apply_scroll`'s remainder is exact only when the wall was already in - view, and a lazy span usually cannot see its wall until it has walked - there -- so `Scroll` reads the child's accumulated movement after the - placing draw instead of adding remainders up, which would drift. -- **One scroll-delta convention, the finger's.** The two widgets had - opposite ones under the same name; `LazySpan::scroll` is now private and - the single negation lives in its `apply_scroll`. Call sites that passed - `-dy`/`-v` pass them straight through, and one fixture's expected - velocity flipped sign with its magnitude unchanged. -- **The transcript builds its `Scroll` by hand rather than through - `.scrollable_to_end()`**, because that helper registers a finger drag - and `Selection` is already the arbiter for those frames -- two - `DragGesture`s seeing one gesture is what `DragGesture`'s own doc rules - out. The wheel is registered identically; only the drag differs. -- **DEFERRED: the pin stays in each widget.** Iris asked for `amt` and - the at-end control to live in `Scroll`; `amt` does, the pin does not, - because applying a pin happens when a row is appended -- between frames, - with no painter -- so moving it needs a fourth `Widget` method or a - parameter on `apply_scroll`. Nothing external edits a pin today. - docs/IRIS_TODO.md carries it. - -## 2026-09-08 (later still: the list's overscroll clamp, in frame) - -Finishes the item the previous entry deferred. IRIS.md has the account. - -- **`List` lays out a second time within the frame** when its walk lands - off the end of the content, instead of writing the correction to the - anchor and asking for another frame. The extra walk is paid only on an - overscrolled frame, and it is mostly O(1) moves. -- **`Painter::draw_again` is removed**, `List` having been its only - caller -- so the framework no longer offers a way to ask for a - corrective frame at all. -- **`List::place`'s top-known and bottom-known cases are one path** - (`Placement::edges`), which is the "write the logic once" rule applied - to two symmetric directions rather than a behaviour change. - -## 2026-09-08 (later: a scroll area measures and places in one frame) - -From Iris's phone report about the composer's padding while typing -newlines, and the rule she stated when she read the first fix: layout is -a pure function of the state, nothing self-heals, and two draws to place -something happen in the same frame. IRIS.md's entry has the account. - -- **`Scroll::draw` draws its child twice** -- once at last frame's length - to measure it, once at the measured length to place it -- instead of - placing against the stale length and leaving a wrong frame on screen. - The second draw is free unless the content's length changed. -- **An end-anchored `Scroll` is at its end on its first drawn frame**, a - consequence of the above. Two layout tests now build their area with - `at_end: false`, which is what they meant: they scroll down from the - top. -- **`List::clamp_to_content`'s next-frame correction is left in place** - and written down in docs/IRIS_TODO.md instead of fixed here, because - `List::place` is a larger piece of machinery and deserves its own - before/after on the phone. - -## 2026-09-08 (every crate to its latest version, wgpu 28 -> 30) - -At Iris's request. RUST.md's "Every crate to its latest version" box has -the full list and the migration. - -- **wgpu 30 taken now rather than pinned at 28.** Two majors of API - change, all mechanical (instance descriptor, optional bind-group and - vertex-buffer slots, `Queue::present`, a `CurrentSurfaceTexture` enum), - and one that would have been a startup abort on a device rather than a - compile error: naga now demands `@interpolate(flat)` on the shader's - integer varyings. Verified on both backends before this was called - done, since a renderer that compiles proves nothing. -- **The desktop instance now carries winit's display handle.** wgpu 30 - asks for it when a GLES surface will be presented on Wayland, which is - what this machine's Vulkan-to-GLES fallback produces. Android passes - none: its surface comes from a `NativeWindow`. -- **`syn` 2 -> 3, `pollster` 0.4 -> 1.0** with no source change in - `iris/macro` or anywhere else. - -## 2026-09-08 evening (the fling is shared; a cancel is not a release) - -From Iris's four-item phone report; RUST.md's "2026-09-08 (evening)" box -has the reasoning and the tests, IRIS.md the summary. - -- **A `Flinger` that does not know which way the content moves.** Every - scroll area flings now, on either axis, as Iris asked -- and the - physics is one type shared by `List` and `Scroll` rather than a copy - each. The choice worth reviewing is the seam: `Flinger` owns the curve - and the clock, and the *caller* owns the sign convention and where the - content ends. Rejected: teaching `Flinger` a direction, which would - have to be told to it -- and being told is the same thing as not - knowing, with an extra field to get wrong. -- **A cancel is a first-class end to a gesture, not an early release.** - `CursorState::cancelled` is new state on the pointer sample, set by - Android's `ACTION_CANCEL` and the harness's `TouchAction::Cancel`. - Rejected: mapping a cancel to `PressEnd` and having each widget decide - what to suppress, which is what shipped and is why leaving the app - flung the transcript. -- **A `DragGesture` ignores a `Cancel` it caused.** One gesture is - driven by several widgets, so the widget that was pressed can be a - "loser" on the frame its own gesture won. The test is whether the - gesture's own capture id is the holder. This is what makes it safe for - every widget driving a gesture to register the whole `drag_senses()` - set, which is now the rule without exception. -- **`List::place` draws a resized row twice in one frame.** The old - comment accepted a one-frame lag by analogy with `Scroll`'s content - length. That analogy was wrong: a stale *length* only misplaces the - next thing, while a stale *box* is drawn, because a background fills - whatever box it is handed. The extra draw is bounded to frames where a - row's height actually changed. - -## 2026-09-08 (iris ships an icon font, and the drawn mark is deleted) - -- **Directed by Iris.** Her question on seeing `widget::mark`: "why does - mark exist? The font should be working if it's working for compose and - nerd fonts are bundled." It was not: the Compose app draws its icons - from **its own committed Nerd Fonts subset**, while iris was setting - the disclosure mark with bare Unicode geometric codepoints - (U+25B8/25BE/25B4) out of whatever face the platform resolved -- an - empty box on her phone, a dot on this VM. The 2026-09-07 entry below, - which said "iris had no equivalent icon font to keep", is what left - that gap: iris had no icon font because it had never had one, not - because it needed none. -- **So iris now bundles the same kind of subset**: - `iris/core/build-icon-font.sh` writes - `iris/core/assets/fonts/nerd_icons.ttf` (992 bytes, three Material - Design glyphs today), `iris::icon` names the codepoints, and - `Family::Icons` draws them. This does **not** reopen the platform-fonts - decision: body and monospace text still come from the platform, and an - icon is the opposite case -- a small, closed, known set of codepoints, - which is exactly the division the Compose app already makes. -- **`iris::widget::mark` is deleted** (added earlier the same day). It - drew a correct triangle, but only a triangle, and every further icon - would have been another bespoke rasteriser. An icon as text also takes - the size, colour and baseline of the line it sits in for free. - -## 2026-09-08 (the emulator is a GLES machine, and Vulkan is verified elsewhere) - -- **Directed by Iris, carried out here**: "make sure the setup uses GL for - the android emulator and remove any vulkan requirements. That'll be - tested through both the desktop version as well as my phone." So the - emulator is settled as a GLES rig and nothing chases hardware Vulkan in - it any more; the Vulkan path is covered by the desktop build and by her - phone. -- **Nothing had to be forced to make that true.** Measured in the guest - the same day: the emulator has no hardware Vulkan at all (its only - Vulkan is SwiftShader, in software) and its GLES is the host's real RX - 7900 XT through virgl at ES 3.1. iris's existing runtime fallback -- - `Backends::PRIMARY`, no adapter, rebuild on `Backends::GL` -- already - lands there, verified end to end with an ordinary (no `force-gles`) - debug APK. -- **The emulator and the phone therefore run the same binary**, differing - only in what that binary finds. That is deliberate and worth not - undoing: a build flag that changed the backend would mean the thing - measured on the emulator is not the thing shipped. `force-gles` stays, - but only for pinning the backend on a machine that *does* have Vulkan - (the desktop), and never for a phone build. -- **Every run now says which adapter drew it.** The Android renderer logs - the full adapter line at startup the way the desktop already did -- only - the backend enum was logged before, which cannot separate `Gl` on the - host's GPU from `Gl` on SwiftShader, or a phone's real Vulkan from a - software one. `run-bench.sh` prints that line before any number. -- **No Vulkan requirement was found in iris to remove.** `device_limits()` - asks for nothing beyond wgpu's defaults (and zeroes the compute fields), - neither backend requires a feature, and both probe rather than - `.expect()` an adapter. What was removed was the *documentation* telling - people to boot the emulator with SwiftShader Vulkan. - -## 2026-09-07 (platform fonts, not bundled ones) - -- **Iris's own decision, carried out as directed**: removed the 3.6 MB of - bundled Noto Sans/Noto Sans Mono TTFs from `iris-core` and load text - from the platform's own font collection instead (`fontique`'s system - discovery, already on by default). Matches what the Compose app does -- - it takes body text from `FontFamily.Default` and code text from - `FontFamily.Monospace`, both platform-resolved, and ships no text font - of its own. Rejected alternative (the one this pass had left open - 2026-09-06): subsetting the bundled Noto Sans to Latin/common - punctuation instead of removing it outright, which would have kept - identical rendering across devices for a smaller (not zero) size cost; - Iris chose to match Compose instead. -- `.so` **-3,748,136 bytes** (11,193,608 -> 7,445,472), matching the - original 3.6 MB estimate. Fallback still lands on the platform's own - tofu for a codepoint no resolved face has (checked with CJK + emoji on - desktop) rather than blank space, so the UI_RULES unknown-glyph rule - still holds. -- **Gap found, then closed same day**: this fontique version's Android - backend never resolved the `Monospace` generic family at all (confirmed - on this checkout's emulator, `mono=None` in the startup diagnostic) -- - two pre-existing bugs in fontique's own `fonts.xml` parsing stacked (an - ordering bug, and a `` declaration whose - `` children the backend's parser never reads), not something this - change introduced, but this change is what stopped masking it (the - bundled mono font used to be registered ahead of the broken platform - lookup, so it always won). Checked `linebender/parley`'s `main` branch - on GitHub: neither bug is fixed there, so there was no newer release to - bump to. Fixed instead in `iris-core` itself - (`TextData::patch_android_monospace`, Android-only): reads - `/system/etc/fonts.xml`'s own `"monospace"` declaration for the font - filename it names, then registers whichever of fontique's actually- - scanned families owns that file as the `Monospace` generic -- 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")`, and a screenshot showing the - bench-fixture's code block and tool-card values in a visibly monospaced - face beside sans body text; the desktop `fontconfig` backend is - unaffected (still resolves monospace correctly, confirmed unchanged). - docs/RUST.md's "Platform fonts (2026-09-07)" has the full account. - -## 2026-09-07 (a phone log reaches Iris through Dev Updater's own tab) - -**Supersedes the "how a phone log reaches Iris" entry below, same day.** -Iris's call once the route was working: put it in Dev Updater properly -rather than smuggling the lines through `ai-server`'s log. - -- **The app exposes its own log on the device, and Dev Updater reads it - there.** A `ContentProvider` at `.devlog`, one table of - lines queried with `?since=` so a poll is incremental, plus a - `status` row (`held`, `dropped`, `newest_seq`). Dev Updater's phone app - polls it while the component's **Runtime** tab is open and forwards what - is new to its own build machine, into that APK component's runtime log - -- so the same tab renders both kinds and the history outlives the - phone. No tunnel, no token, no second enrolment: the two apps are on the - same phone. - - **It is a contract, not a feature for iris.** Written down in - dev-updater's `README.md` ("An app's own log"), so any app that server - delivers gets the tab by implementing it; the Compose app in `app/` can - do the same later. That is the reason it beat the route below on its - second look -- the earlier one only ever worked for the one project that - had a server, and put a phone's lines under a *different component* than - the one they came from. - -- **Read access is `protectionLevel="normal"`, and that is a real trade.** - `signature` is what this wants and is not available: Dev Updater and the - apps it delivers are built on one machine but signed with different - locally generated keys, so a signature permission would be held by - nothing at all. What `normal` costs is that any app on that phone which - requests `dev.updater.permission.READ_DEVLOG` by name can read another - app's dev log. Accepted because these are development builds on a - development phone and the alternative was no log; stated in the manifest - beside the declaration and in dev-updater's README so it is not - rediscovered as a surprise. - -- **The provider polls rather than notifying.** `notifyChange` was not - implemented: the ring is filled by a `log::Log` backend on whatever - thread logged, and giving that a route to a `ContentProvider` means - plumbing a callback through `client-core` for every platform. Dev - Updater's contract therefore says it polls (about a second, only while - the tab is open), which is what keeps implementing the contract cheap -- - a provider that does notify loses nothing. - -- **What was deleted, so there is one mechanism**: `client-core`'s - `log_upload` module, `POST /client-log` on `ai-server`, the - `AI_APP_LOG_HOST`/`_PORT`/`_TOKEN` baking in `iris/android-app/build.rs` - (which left that file with nothing to do, so it is gone too), and the - uploader fields on both Android clients. Kept: the ring, `RingLogger`, - `install_process_logger`, and the Diagnostics line counting what is - held. The upload-status line there is now **"devlog provider: - content://"** -- named from what the provider registered - rather than composed from the package here, so a screenshot of that pane - is evidence the contract is live and says which package's log it is. - -## 2026-09-07 (how a phone log reaches Iris) -- superseded, see above - -- **The app sends its own log to `ai-server`, and Dev Updater shows it as - `ai-server`'s runtime log.** Iris has no `adb`/`logcat` on her phone, and - Android forbids one app reading another's logcat, so the app has to carry - its own copy and post it somewhere. `POST /client-log` on `ai-server` - re-emits each line into that server's own `tracing` output; Dev Updater - already runs `ai-server` as a `Managed` component, whose stdout its own - service script redirects to a file and reports through - `GET /apps/{key}/components/{name}/logs?kind=runtime`, which the phone - app's log dialog already offers as a **Runtime** tab for a `server` - component. So **no change to Dev Updater at all** -- one route on - `ai-server`, and the client in `client-core`. - - **Rejected: posting to Dev Updater's own server** (the first candidate, - and what the entry above went on to build -- the estimate below was - right about the work and wrong about it being too much). - It would need a new authenticated *write* route on a TLS surface whose - module doc says every route on it "is, or decides, the bytes that get - handed to `REQUEST_INSTALL_PACKAGES` next"; a per-app device-log store; - a change to `component_logs` so an APK component can have a runtime log; - a change to the phone app's `hasBothKinds = component.kind == "server"` - gate and to what `hasRuntimeLogs` means on the wire; and -- the real - cost -- a **second** enrollment for the iris app, since it has no CA or - token for Dev Updater and Dev Updater mints tokens per device by QR. - Five changes across two repos against one route, for the same line - landing in the same viewer. - - **Rejected: a share intent from a debug button** (a log file in the app's - external files dir, shared by hand). It works today and needs no server, - but every line costs Iris a manual export and a message, which is the - round trip through a person this was meant to remove. It is still the - fallback when the tunnel is down, and GrapheneOS's own per-app log export - already covers the crash case (that is how the `ToolInput.highlighted` - crash was reported). - -- **The ring is in `client-core`, not in the Android crate.** A bounded - in-memory ring (2000 lines or 256 KiB, whichever bites first) behind a - `log::Log` backend that *forwards* to whichever logger the platform - already installed, so `logcat` and a desktop terminal see exactly what - they saw before. The platform supplies only its own logger and its - destination. `Copy report` appends the ring to what goes on the - clipboard, and flushes the uploader first. - -- **The destination is baked in at build time, from the build machine's - own files** (`AI_APP_LOG_HOST`/`_PORT`/`_TOKEN` plus the pinned CA) -- - *gone; the provider above replaced it.* What is worth keeping from it is - the reason it went: an APK good only for the server that built it cannot - be built in this VM for Iris's phone, which is the case that mattered. - all three or none, never two. The same trust boundary the transcript - config and the Compose APK's CA already use: nothing secret is - committed, and an APK is good for the server that built it. A build told - nothing still keeps its ring and still copies it; the diagnostics pane - says which of "not tried yet", "failing -- " and "no server - configured" it is, because otherwise all three look like silence. - -## 2026-09-06 (how a tool call looks, P1b) - -- **A card that never got a result says "no result", in yellow, and it is - a state Compose cannot say.** A call that finished having printed - nothing and a call whose turn was interrupted before anything came back - both leave an empty output. Compose draws both as an ordinary finished - call, which reads as a fact somebody established. There are five states - now, each with a word and a colour: nothing at all for a call that - worked, "running" (grey), "your turn" (peach, Compose's own wording and - colour), "failed" (red), "no result" (yellow). - -- **A failed call is drawn as failed, which needed a field on the wire.** - `is_error` is on the CLI's `tool_result` and was being dropped; the - server now carries it to the phone. Reversible, but the alternative is a - card that says a call succeeded because it cannot tell. - -- **A group's cards do not each carry their own surface.** Compose gives - each card a fill and squares the corners where it faces a neighbour, so - a run reads as one object broken into parts. iris has no per-corner - radius, and -- more to the point -- a group built the way Compose builds - it hit a framework layout defect that drew every card's text a card - below its own box. So a group is one surface with its cards on it, - separated by a small gap, and the 4dp inset Compose holds them off the - edge by is gone. Worth revisiting once the layout defect is fixed - (docs/IRIS_TODO.md). - -- **A long tool output is capped at 80 lines or 4 kB with a "Show all N - lines".** Compose draws the whole thing, and gets away with it because - its `Text` inside a `LazyColumn` lays out lazily; here the output is one - text widget and shaping a hundred kilobytes of it costs what the file - editor's 32 kB limit was measured against. If iris's text gets cheaper, - this is the number to move. - -- **A card's command is clipped, not pannable, and its summary line is - clipped rather than ellipsised.** Both are framework gaps rather than - choices (`scrollable_on` on a non-editable text draws nothing; there is - no overflow ellipsis), and both are worse than Compose today. Named here - because they are visible. - -## 2026-09-06 (how a markdown block looks, P1a) - -- **A table is drawn as padded monospace columns, not as a grid.** Your - call to reverse. Compose draws a real grid: cells on a tint, each - column with a 136dp floor, scrolling sideways when there are too many. - iris has no grid widget, and building one would be a widget per - markdown feature -- which is the thing the block model exists to avoid. - In a monospace face a character count *is* a pixel width, so padding - each cell to its column's width is alignment, the widths are still - measured from the cells, and a table that is too wide pans sideways - through the same mechanism a code fence already uses. The header is - bold with a rule under it, and a long cell wraps inside its column - (capped at 28 characters, which is what fits three columns across a - phone). **What it trades:** no cell borders, and a table looks like - code rather than like a table. If you want the grid, it is a new widget - and it is a day's work. -- **Three block frames, and only three.** A heading, paragraph and list - are plain text with spans; a fence and a table are a rounded panel that - does not wrap; a quote is a bar with the text padded past it. - Everything else markdown says is expressed in span styles, which cost - no widgets and no layout nodes. So a new markdown feature is a span, - not a widget. -- **A list's marker is part of the text, so a wrapped item's second line - returns to the left margin.** Compose keeps it indented by giving the - marker its own column. Doing the same here needs per-line indent in - iris's text attributes; it is written down rather than done, because - the list items in a real reply are usually one line. -- **A link opens on a tap and not on the end of a drag.** A press that - panned the transcript past a link, or that held long enough to start a - selection, does not follow it -- decided by the same gesture machine - that decides pan-versus-select, so there is one rule rather than two - that can disagree. - -## 2026-09-06 (composer scroll and the streaming block model) - -- **A streamed message becomes a column of per-block widgets.** Decided by - the design agent; recorded here because it is the shape of every message - on screen. A transcript row is one `TextEdit` today, so a streamed delta - re-shapes the entire message through parley on every event -- the stream - phase is the one place iris is behind Compose on your phone (p50 18.2ms - vs 13.4ms). A row becomes a column of one widget per markdown block - (paragraph, heading, fence, list, table) and a delta replaces only the - last block, keeping every earlier block's layout. **Rejected:** splitting - parley's layout at block boundaries inside one text widget (couples - iris's text widget to markdown structure, and parley has no incremental - API), and caching shaped runs per paragraph inside `TextEdit` (a second - cache with its own invalidation beside the glyph cache). Chosen because - P1's markdown block model is needed anyway, so the split happens once, in - `client-core`, and iris stays a text renderer. **Status: designed, not - built** -- this pass spent its budget on the composer's three layout - defects; docs/RUST.md has the design and the pass conditions. -- **The composer's overflowing text now scrolls on a finger**, capped at - six lines and clipped to the bar. Reverses the "still does not scroll" - item below. -- **A widget may not report a `dp` length** (see IRIS.md). A rule for - widget authors, enforced by a `debug_assert!`; nothing changes for app - code. - -## 2026-09-06 (stale-primitives and touch-scroll pass) - -- **A vertical drag inside a focused composer now scrolls rather than - selects.** Android's own `EditText` does this -- a vertical drag scrolls - the field, and only a long press starts a selection -- so the platform - decided it. What it costs: you can no longer drag straight down inside - the composer to select several lines of what you typed; use a long press - and then drag, or drag sideways. Say if that trade is wrong for you. -- **`Scroll` gets a finger pan but no fling.** `List` flings; a scroll area - does not, because it has no per-frame tick to animate one and the areas - it wraps are at most a screenful (Android does not fling a six-line text - box either). Easy to add later if a scroll area ever wraps something long. -- **The composer still does not scroll its overflowed text**, though the - mechanism it needs is now in place. Wrapping the field in `.scrollable()` - was tried and reverted the same day: `Scroll` measures its content and - container against the *window*, so inside the `MaxSize` that caps the - composer at six lines the two are in different spaces and the field pans - itself entirely out of the bar (measured on the emulator with 474 - characters in it -- the bar collapsed to its padding). Fixing that means - `Scroll` measuring against its own offered box, which is a change to a - widget the transcript and the bench shell both use, so it is its own - piece of work rather than a rider on this one. - -## 2026-09-06 (defect pass) - -- **The keyboard-open diagnostics overlay is gone; the capture only - logs now.** It was added when `on_insets_changed` was not firing at all - and there was no way to get a report off the phone. It fires reliably - since the activity went edge-to-edge -- and what that looks like in - use is a full-screen report covering the app **every time the keyboard - opens**, with its own Copy/Close buttons sitting underneath the - keyboard, so it cannot be dismissed (reproduced on the emulator this - pass: two `tap 'CLOSE'` runs left it up). An interruption for something - nobody asked for, over the app you are trying to type into. The named - `Diagnostics` button still shows the same text on demand, and the new - `iris surface:`/`iris insets:` log lines carry the lifecycle a `logcat` - pull needs. Reversible: `capture_keyboard_diagnostics` is still the one - place this is decided, and `PlatformHandle::show_diagnostics_overlay` - is still there. - -- **The bench shell's report pane is sized to its report, not to a share - of the window.** It held `.height(rest(1))` beside the transcript's - `rest(2)`, so an *empty* `TextEdit` reserved a third of every screen -- - which is what Iris's "the app does not start with keyboard spacing - correct" screenshot was showing, with the composer two thirds down and - black below it. It is `.max_height(dp(260))` now and sits above the - transcript rather than under the composer, where it was eating the - navigation-bar clearance. Cost: a filled report is clipped at 260dp - rather than scrolling (a `Scroll` there drew itself off the top of the - screen, since `Scroll` pins to the end of its content and reports its - content's full length to the parent -- worth fixing in `Scroll`, not - worked around here). "Copy report" and `logcat` still have the whole - thing. - -## 2026-09-05 - -- **iris no longer asks every device for compute-shader limits it never - uses.** `adapter.request_device` (both `iris/src/android/render.rs` and - `iris/src/default/render.rs`) used `Limits::default()` plus an override - for `max_buffer_size`, and `Limits::default()` unconditionally requests - desktop-tier compute limits (`max_compute_workgroups_per_dimension: - 65535`, per `wgpu_types`) even though nothing in `iris`/`iris-core` - creates a `ComputePipeline` or writes a `@compute` shader stage — - confirmed by grepping the whole tree, not assumed. That crashed - `request_device` outright on the Android emulator's software GL path - (`EMU_GPU=software`, `--features force-gles`): SwiftShader's GL reports - itself as OpenGL ES 3.0, which has no compute shaders at all, so the - adapter's real limit is 0 against the unconditional request for 65535 — - `RUST.md`'s "Software mode ... crashes for a third, different reason," - 2026-09-05, earlier today. The same would happen on any real - GLES-3.0-only Android device, not just the emulator. Fixed by a new - `iris_core::device_limits()` (`iris/core/src/render/mod.rs`), shared by - both platform backends so the two requests cannot drift, that zeros the - six `max_compute_*` fields explicitly rather than switching to a - downlevel `Limits` preset — `Limits::downlevel_webgl2_defaults()` was - considered and rejected: it also zeros - `max_storage_buffers_per_shader_stage`, and `shader.wgsl`'s vertex stage - reads four `var` buffers (rects, glyphs, masks, move_offsets), - so that preset would trade the compute crash for a bind-group-layout - one on the same downlevel hardware this is meant to support. No - capability check or fallback path was needed since nothing is being - disabled — the request is simply narrowed to what the pipeline actually - uses. `rigs/gpu-probe`'s own mirrored limits (it is deliberately its own - crate, not a workspace member, so it cannot call `device_limits()` - directly) were updated to match, and confirm `IRIS DEVICE: ok` against - this VM's own Vulkan and GL adapters. **Not verified this pass**: the - specific SwiftShader-ES-3.0 crash this fixes, on-device — the - `EMU_GPU=software` cold boot this needs would have force-restarted this - checkout's emulator while another session was actively running its own - app on it (`com.example.aiapp` had window focus at the time), so it was - left for a pass when the emulator is free rather than disrupting that - session. Everything reachable without the emulator is clean: `cargo - fmt`/`clippy --workspace --all-targets`/`test --workspace`, `cargo ndk - build`/`clippy` for `iris-android-app` with `force-gles`, and - `gpu-probe` against this VM's own Vulkan and GL(ES 3.2, which still has - compute and so would not have reproduced the crash even before this - fix — not a substitute for the real ES-3.0 test). - -- **P0's Compose half is built and smoke-tested on the emulator** — the - `bench` build type, the shared `app/bench-fixture/` transcript, and an - in-process fake backend (`BenchFixture.kt`/`BenchNetwork.kt`) that - answers `TranscriptSource`/`EventStream` from an in-memory event log - instead of a real server, so the fold and paging under test are the real - ones. Full account, the smoke run's report, and what is deliberately - left (the iris half, the real on-phone runs) are in RUST.md's P0 box. - Not a decision to review so much as the gate itself now being runnable — - flagged here because it is the first half of something Iris explicitly - asked to see before P1. - -- **P0's iris half is also built and smoke-tested on the emulator, - 2026-09-05.** A new `bench` Cargo feature on `iris-android-app`, on top - of `transcript-screen`: the same checked-in fixture (`include_str!`, no - asset pipeline needed), the same 24-swipe scroll loop animated through - `List::scroll` and the same 400-event/20s streaming phase through - `fold_event`, "Run benchmark"/"Copy report" as named accessible - controls, and the same three added report fields (process CPU time, - peak RSS, battery current) via direct JNI calls - (`bench_jni.rs::PlatformHandle`) since `android_view` has no - `BatteryManager`/`ClipboardManager` wrapper of its own. One small public - API addition to get there: `AndroidAppState::platform_ready` (`IRIS.md`), - a default-no-op lifecycle hook handing an implementor a `JavaVM` + - `GlobalRef` it can call Java through from any thread. Packaged with a - new `release` build type on `iris-android-app`'s own Gradle project - (there was previously only `debug`), signed with the same key - `app/build-apk.sh` generates. Smoke run and the full report are in - RUST.md's P0 box; not attempted this pass: the real on-phone runs and - Iris's pass/fail call, which is the actual gate. - -- **The intermittent touch-scroll dropout is root-caused and fixed: a - missed `ACTION_DOWN` hit-test, not the previously-suspected coalesced - first `ACTION_MOVE`.** Diagnosed by temporary logcat tracing of every - touch event, `DragArbiter` state transition and `Selection::drag` - dispatch (removed once confirmed), reproduced on this checkout's own - emulator against a real sandbox session. The trace showed the actual - mechanism: a gesture's `ACTION_DOWN` lands wherever the finger actually - is, which is not guaranteed to fall inside the same row-local sensor - region a later `ACTION_MOVE` in the same gesture lands in (a row's own - padding/gap, or its non-selectable sender-name header, is - pointer-transparent to `iris::sense::CursorSense`). When that happens, - the widget that ends up handling the gesture never saw `PressStart`, so - `DragArbiter` sits in `Idle` — which answers every subsequent frame with - `Undecided` and has no way to tell "no press is happening" from "a press - is happening but I missed its start," so it never recovers on its own - for the rest of that gesture. One real trace showed exactly this: touch - `Down`/`Move`/`Up` all delivered correctly, but zero `PressStart` - reaching the arbiter, `state=Idle` unchanged from first frame to last. - Fixed at the call site that has the context to recover - (`iris::transcript_ui::selection::Selection::drag`, - `iris/transcript-ui/src/selection.rs`): a new `DragArbiter::is_idle()` - (`iris/src/sense.rs`) lets it notice a `Pressing` frame arriving with the - arbiter still `Idle` — which can only mean a missed `PressStart`, since a - `Pressing` sense requires the button to genuinely be down — and start the - press there instead of where it was missed. Three new unit tests in - `sense.rs`'s `drag_arbiter_tests` and one in `transcript-ui`'s - `selection::tests` (the latter fails on the code before this fix). - Commit follows. Not the same failure the earlier pass's `DECISIONS.md` - DEFERRED item speculated about (a coalesced first `ACTION_MOVE` skipping - slop detection) — that hypothesis is now ruled out; the arbiter's own - slop/long-press logic was never wrong. RUST.md's I5 box, - "Touch-scroll dropout root-caused, 2026-09-05" has the full trace. -- **P0, a phone benchmark gate before any porting, asked for by Iris - 2026-09-05**: "before P1 I'd like to see benchmarks & also maybe stress - test on my own phone ... If it doesn't match compose reasonably well then - I don't think I'd wanna continue." Design (RUST.md's P0 box has the - detail): the same embedded synthetic fixture in both apps with no server - needed; the same scripted scroll loop then a streaming phase, run - programmatically since the phone has no usable system tracing and no - agent can drive it; the same report from both (frames, janky %, p50/p90/ - p99, process CPU time, peak RSS, battery current where readable) with a - copy button; the iris app under its own id and the Compose one as a new - `bench` build type with an id suffix, so neither replaces her production - install; two arm64 APKs plus instructions delivered under `~/host/bench/`. - The gate is hers: iris within a reasonable margin of Compose release on - p50, p99 and CPU time, no crashes, no visible stutter. If it fails, the - port stops. -- **The rest of the port is one UI crate, `iris/app-ui`, grown out of - `iris/transcript-ui` rather than started beside it.** It holds a - `Screen` enum plus a back stack — the Rust equivalent of `AppRoot.kt`'s - `when` — and `iris/desktop-app`/`iris/android-app` become thin entry - points over it. Chosen over a fresh crate because `transcript-ui` - already has the right generic shape (`Rsc: HasEvents` + - `Rsc::State: FocusHost`) and the `client-core`/`event-model` path - dependencies every later screen needs, so growing it in place is the - smaller diff. Platform-only code (notification service, share target, - QR scanner, Keystore token, deep-link enrolment) stays in the E3/E5 - Java shell (`android-shell/` + `app/shellApp`) rather than moving into - this crate, since none of it is a screen. The Android APK is built by - `cargo xtask apk` (E5), merging the app-ui cdylib into the E3 shell so - there is one app rather than a demo shell plus a service shell. - `app/androidApp` (the Compose app) stays untouched and is the baseline - every step is measured against, until parity is reached (P7 decides - the switch, and is itself a load-bearing decision left to Iris). Order - is by risk to the daily-use path: session screen first (P1, where - every hard behaviour already lives), then the shell merge and a real - phone install (P2), then root tabs (P3), the explorer (P4), - settings/enrolment (P5), desktop parity (P6), and the cutover itself - (P7). Full plan: RUST.md's "The port, in order (decided 2026-09-05)". -- **iris gets its own measured frame report, rather than waiting on a - `dumpsys`/`gfxinfo` answer that cannot see a `SurfaceView`'s GPU-drawn - frames.** `iris_core::FrameReport` (`iris/core/src/render/frame_report.rs`) - times each frame's wall clock from the same point `render()`'s redraw - starts to just after `queue.submit` + `present()` — the span Compose's - own render report and `gfxinfo` both count — into a fixed 4096-entry - ring (no allocation per frame; `report()` is the only place that - allocates, and only on a button tap). The report gives total frames, - janky % over the same 16.7ms budget `gfxinfo` uses, P50/P90/P99 and the - worst, plus a reset. Exposed the way the Compose app's copy-button - report already is: two named controls ("Frame report", "Reset frame - report") on the transcript screen, tappable by accessibility name via - `ui-trace`, logging under this crate's fixed `android_logger` tag - (`iris-android-app`) so a script can grep `"iris frame report"` the way - `transcript-bench.sh` greps `"ai-app render report"`. The report's own - `Display` line says plainly that it measures up to the `present()` call - returning, not GPU/compositor completion — wgpu's `present()` is not - fenced against either, so presenting that span as "time to reach the - screen" would be a measured-looking number that is actually inferred, - which the standing UI rule forbids. -- **`ui-trace` gains a hold-then-drag gesture, additive, in - `emulator-tools`.** Neither of its two existing actions can produce - "hold stationary for `LONG_PRESS`, then move without lifting" — `tap` - has no hold and `swipe X1 Y1 X2 Y2 MS` interpolates motion across its - whole duration from t=0. A new action presses, waits, then moves to a - second point and releases as one continuous touch (raw - `sendevent`/`MotionEvent` injection, extending whatever mechanism the - existing `swipe` already uses), so `DragArbiter`'s pan-vs-select rule - (`iris/src/sense.rs`, already covered by 8 unit tests against a - synthetic clock) can finally be driven on a real device instead of only - in a test harness. -- **Touch drag on a transcript row follows Android's own rule**: a vertical - drag pans the list immediately; a stationary press held 500 ms starts a - text selection which further dragging extends; a horizontal drag while - something is already selected extends that selection without the wait. - One `DragArbiter` per list decides it (`iris/src/sense.rs`). Chosen over a - "text layer always wins" or "list always wins" rule because either loses - one of the two gestures a reader expects. -- **E4's desktop shape is a new `iris/desktop-app` crate**: a winit window - holding `transcript-ui`'s screen beside a session list, talking to a real - `ai-server` through `client-core`. It enrols by pasting the same - `aiapp://enroll?…` link a phone scans (`client-core::config::EnrolledServer`) - and keeps it owner-only under `$XDG_CONFIG_HOME/ai-app-desktop/`. The - pinned CA is a path given on the command line, not baked in. Chosen so - the phone and desktop share one enrolment format and no second one is - invented. -- **I5's Android integration extends `iris-android-app` (I2's shell) - behind a Cargo feature (`transcript-screen`), rather than a third - shell crate.** That project already has the Gradle module, the - `IrisView`/`MainActivity` Java, and the JNI registration; the only - thing a second screen needs on top is a different `AndroidAppState`, - the same axis `tabs_ui::build`/`transcript_ui::build` already vary - along on the winit side. `tabs-screen`/`transcript-screen` are - mutually exclusive and each pulls in only its own deps, so the plain - tabs build (I2/I4) is untouched. -- **Order of remaining work, updated 2026-09-05**: the two in-flight - pieces and I5's Android integration are all done; next is giving iris - its own frame-timing report so item 3 below can be decided by a number. -- **DECIDED by Iris, 2026-09-05: iris is the app's framework; Masonry was - the calibration.** Her words: "I think iris definitely makes more sense - based on the limitations we've found." The limitations: Masonry has no - touch scroll on Android (E2), no per-span rich text and no cross-row - selection on the pinned commit (E2), and its keyboard bridge is a TODO - (E1); iris carries the same screen under the Compose baseline on the - host GPU (p50 15.0 ms against Compose's 20.0 ms, RUST.md's I5 box). What - follows: the E-steps are closed as calibration, and the port proceeds - on iris — screens, the shell (E3/E5), and `client-core` underneath. - The item below is kept as the record of what she decided from. -- **Was DEFERRED — whether to commit to iris over Masonry for `ai-app`.** - Updated 2026-09-05 with the clean comparison the recommendation wanted: - same sandbox session content, same emulator, `EMU_GPU=software`, one - session. Headline numbers (RUST.md's I5 box, "Clean scroll comparison, - 2026-09-05," has the full table and every caveat): - - | app | build | frames | janky % | p50 | p90 | p99 | worst | - |---|---|---|---|---|---|---|---| - | Compose (in-app report) | debug | 1102 | 99.0% late | 33.8ms | 50.6ms | 79.5ms | -- | - | Compose (`dumpsys gfxinfo`) | debug | 1499 | 21.15% (95.66% legacy) | 32ms | 48ms | 150ms (p99) | -- | - | iris (`FrameReport`) | **release** | 299 | 94.65% | 79.1ms | 98.6ms | 117.8ms | 212.6ms | - | iris (`FrameReport`, repeat) | **release** | 233 | 94.42% | 109.3ms | 130.8ms | 147.1ms | 150.5ms | - - **Not a clean apples-to-apples reading, stated plainly rather than - smoothed over**: iris had to be built **release** (debug `SIGSEGV`s on - this emulator's Vulkan loader, I4's finding) against Compose's mandated - **debug** build, so this asymmetry likely *understates* iris's gap - rather than the reverse; the three frame-time sources measure different - things (Compose's own phase accounting vs. Android's HWUI deadline-miss - definition vs. iris's redraw-start-to-present window, the last of which - `dumpsys gfxinfo` cannot see at all for iris's `SurfaceView`); and both - figures are emulator numbers under software rasterisation, which - Compose's *own* in-app report shows already costs 20-34ms/frame in - `swap`+`gpu` alone under this GPU mode, so a same-mode iris number well - above 16.7ms was expected going in for either app. A second pair under - `-gpu host` was not taken this pass. The earlier session's suspected - intermittent touch-delivery dropout was **not reproduced** this pass — - the zero-frame results this time traced to this pass's own script bug - (a `cd` that changed which emulator `ui-trace` targeted), not the - emulator; a CPU-load rise during the gesture was observed by a sampler - running throughout, but did not correlate with any failure, so the - original candidate is neither confirmed nor ruled out. - The choice in front of Iris, updated: decide now on the - structural-plus-functional case already made (iris works end-to-end - where Masonry's scroll gesture doesn't exist at all on Android) plus - this table — reading the two build profiles and three jank definitions - with the caveats above rather than as a single number — or ask for a - same-profile, same-GPU-mode rerun first. RUST.md's I5 box has the full - account. - - **Updated 2026-09-05, the `-gpu host` pair taken.** Real GPU rendering - (`force-gles` -- the default Vulkan backend has no adapter at all under - plain host-GPU boot, confirmed by the exact `wgpu` error) reverses the - software-mode shape: - - | app | build | GPU mode | frames | janky % | p50 | p90 | p99 | worst | cpu p50 | gpu-wait p50 | - |---|---|---|---|---|---|---|---|---|---|---| - | Compose (in-app report) | debug | host (virgl) | 1268 | 96.4% late | 20.0ms | 28.4ms | 37.7ms | -- | -- | -- | - | iris (`FrameReport`), **best of three, 2026-09-05** | release, `force-gles` | host (virgl) | 439 | 46.24% | 15.7ms | 23.3ms | 31.2ms | 57.4ms | 1.2ms | 13.2ms | - - Under real GPU rendering iris's median frame is *faster* than - Compose's, not the 2-3x-slower shape the software-mode table shows. A - new split inside `FrameReport` (redraw-to-submit vs. submit-to-present, - commit `e2a1fad`) says why: iris's own CPU work per frame is a median - ~1ms -- almost the entire frame is time spent handing the frame to the - driver, not in iris's layout/text/primitive code. This is consistent - with the earlier software-mode gap being mostly SwiftShader's CPU - rasterisation cost rather than an iris-specific slowness. **Still not - proof, and now closed as unanswerable rather than merely untaken**: a - same-mode software `force-gles` run to isolate the backend was retried - 2026-09-05 after fixing the compute-limit crash the first attempt hit, - and hit a second, structural wall instead — SwiftShader's ES 3.0 GL - path has no storage-buffer capacity at all, and `shader.wgsl` reads - `var` buffers unconditionally, so reaching that path needs a - shader rewrite, not a limits fix (RUST.md's I5 box, "The three - remaining I5 verifications, closed 2026-09-05," item 2). The - intermittent touch-scroll dropout this pass also reproduced is - root-caused and fixed as of the same date (a missed `ACTION_DOWN` on a - row's padding/header left `DragArbiter` stuck in `Idle`); three clean - `iris-scroll.sh` runs post-fix each scrolled all 24/24 swipes, replacing - the single-attempt 62-frame reading this table used to carry. RUST.md's - I5 box, "Where iris's frame time goes, 2026-09-05, the `-gpu host` - pass," and "The three remaining I5 verifications, closed 2026-09-05," - have the full account. The iris-vs-Masonry choice itself is still - Iris's to make. - -## 2026-09-07: the enrolment link carries the CA, so an APK need not be built where its server runs - -**Problem.** Every phone build pinned the CA of the machine that compiled -it -- the Compose app from `GeneratePinnedCert`, the iris app from -`build.rs` reading `$XDG_CONFIG_HOME/ai-app/certs/ca.pem`. That is fine -while the two are the same machine and impossible when they are not, which -is exactly the iris client's situation: cross-compiled in this VM, -delivered to a phone, run against `ai-server` on the host. Baking the -host/port/token as well made it worse -- a token in a built artifact. - -**Decided: the CA rides in the enrolment link**, as `&ca=` (`wg_app_link::enroll::ca_param`), optional and per mint. The app -that opens the link pins what the link said, and an APK built anywhere -works against whatever server it is pointed at. - -Two alternatives were worked out and rejected. - -- **A CA *fingerprint* in the link, pinned at the TLS handshake.** The - smallest link (43 more characters) and the strongest shape, but `ureq` - 3.4 exposes no hook for a custom `rustls` `ServerCertVerifier`: its - `TlsConfig` builds the `ClientConfig` itself, so this needs a hand-written - `Connector` on the `unversioned` API and `rustls` as a direct dependency - of `client-core`. A lot of machinery in the one crate that must stay - light. -- **A fingerprint in the link plus an unauthenticated `GET /ca.pem`.** - Small code, but it needs a first connection with verification disabled, - and it breaks a documented, tested posture -- `auth.rs`'s "gates every - route with zero unauthenticated endpoints", which is a load-bearing - decision rather than an implementation detail. Not something to change - silently for this. - -**What it costs**, measured rather than guessed: on this project's P-256 -CA the link goes from 89 bytes to 652, and `print_enrollment`'s terminal -QR from 45x23 to 93x47 characters. That is why the parameter is the -minter's choice per call: `ai-server` passes it (its iris client needs it), -`dev-updater` passes `None` (its app is built on the machine it talks to, -and its QR stays scannable in an 80-column terminal). The URI printed under -the QR is the fallback either way, and is the path Dev Updater's Enroll -button already uses -- it opens the link with `ACTION_VIEW`, so Android -offers whichever apps registered the scheme, which needed no change here. - -The CA is a public certificate, so putting it in the QR leaks nothing the -token did not already: photographing the terminal still costs exactly the -token, which is rotatable. - -**The log upload's destination is moot**, so it is not wired to this. On -the same day Iris decided Dev Updater will read an APK's runtime log from -an on-device ContentProvider instead, which removes `log_upload`, -`POST /client-log` and the `AI_APP_LOG_*` baking altogether -- so the -enrolment landed without touching any of them, for that change to delete -whole. diff --git a/docs/IRIS.md b/docs/IRIS.md deleted file mode 100644 index 506ff8b..0000000 --- a/docs/IRIS.md +++ /dev/null @@ -1,1986 +0,0 @@ -# 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. - -**Paths in older entries are pre-2026-09-08.** The port's crates became -one crate on that date -- `client-core` -> `app-rust`'s `src/client`, -`transcript-ui` -> `src/ui`, `transcript-fixture` -> `src/ui/fixture.rs` -and `tests/`, `desktop-app` -> `src/desktop`, `android-app` -> -`src/android`, `android-shell` -> `src/shell`. Entries are left as they -were written rather than rewritten, since each is a dated record; -`docs/RUST.md`'s "One app crate" is the mapping. - -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 (newest): a `LazySpan` clips itself, and nothing is unbounded - -Four things you asked for, in one change. - -**A lazy span knows nothing about masks.** It used to assert that -something around it had called `.masked()` and refuse to draw otherwise, -which is why a plain full-screen list -- the benchmark, any simple app -- -panicked on its second line. What it does now is only the part that is -its own: it *culls*, so a row entirely outside the box it was offered is -never drawn, and a row straddling an edge is still drawn in full, because -virtualisation decides which rows and never how much of one. Whoever -wants that overhang cut off adds `.masked()`, exactly as whoever wants -scrolling adds `.scrollable()` -- your words: "masking should be opt in". -The transcript opts in, because it is a list under a header bar; a -full-screen list does not, and the widget has no business assuming either. - -I got this wrong once on the way: the first version had the span set a -mask of *itself*. That fixes the panic and is still the widget deciding -something that is not its to decide -- a caller already clipped by -something bigger ends up double-masked, and one that wants the overhang -has no way to say so. Worth recording as the shape to avoid, since it -looks like the tidy answer. - -**Everything on the transcript screen is capped now.** One rule in one -place -- `client_core::text_cap`, mirrored as `TextCap.kt` with the same -numbers, so a bench comparing the apps compares renderers and not -policies: - - a tool call's input 80 lines or 4 KiB -> "Show all N lines" - a tool call's output 80 lines or 4 KiB -> (already was) - a message 200 lines or 16 KiB -> "Show all N lines" - -The input is what your edit card needed: an `Edit`'s `old_string` and -`new_string` arrive whole and are routinely the biggest text on screen. -Messages are capped for the reason you gave -- both user and agent, in -both apps. - -Three rules that took a screenshot to get right. A message is cut on a -**block boundary**, not mid-block: cut to its own opening line a fence -renders as an empty panel, which reads as a fault rather than as a cap -(the exception is a message that is *one* enormous block, which is -truncated, since dropping it would leave the row blank). A reply still -streaming is **never** capped, because a row that stopped growing at two -hundred lines while the model was plainly still writing reads as the -stream having died. And the input's two blocks share **one** "Show all", -since they are two halves of one answer -- while input and output have -their own, since wanting the whole of a `new_string` says nothing about -wanting the whole of the build log under it. - -**The Compose app does not wrap raw text any more**, per your call: a -tool's leftover input fields and its output pan sideways like the command -already did. A wrapped log destroys the column alignment that carried its -meaning, one line at a time and only on the long lines -- so iris was -right and Compose was the one to change. - -## 2026-09-08: redrawing one widget cost O(its own primitives squared) - -Your report -- expanding a tool card with a long horizontally-scrolling -edit in it lags -- is a framework defect, not a text-layout one, and the -size of it is not close: **a 51,200-glyph block took 1.37 seconds to -redraw, of which shaping and rasterising the text was 20ms.** It is 29ms -now, and the cost is linear in the glyph count rather than quadratic. - -What happened. A widget redrawn in place frees every primitive it owned -and writes fresh ones. Freeing compacts each layer's draw order with -`swap_remove`, and every primitive that gets swapped into a hole has to be -told its new position -- so a widget with N primitives generates ~N -renumberings. Finding the handle to renumber was a **linear scan of -everything that widget drew**, which made the pass N^2. For a paragraph -that is nothing. For one text widget holding a whole `old_string` and -`new_string`, N is every glyph in the card. - -The fix is a back-pointer: the primitive arena now records, per slot, -where that slot's handle sits in its owner's `ActiveData::primitives` -(`Primitives::handle_index`), written at the one place a handle is taken -(`Painter::own`). `apply_free` indexes straight to it. - - 50,000 glyphs, redrawn: before 636ms/redraw after 2.4ms/redraw - per glyph: before 12.7us after 0.043us (flat in N) - -`benches/message_list.rs` grew scenario **(g)** for it, and the number to -read is per-glyph: flat as N grows is the pass condition, and a total -hides it. That file had also stopped running at all -- scenarios (a) and -(e) built a `LazySpan` with no mask around it, which the span asserted -against, so the whole benchmark panicked on its second line. Worked around -here and fixed properly in the entry above, which deletes the assert. - -**What this does not fix**, and what the entry below closes: an open card -still shapes, rasterises and submits *every* glyph of its input and -output, not the screenful you can see. iris does not cull within a widget, -so the only bound available is a cap on what goes in. - -## 2026-09-08: one `ScrollController`, a `Scrollable` trait, and `Pin` - -Your three points on `docs/SCROLL.md`, in one change. The shape is the one -you proposed: **a controller both scrolling widgets contain**, rather than -a protocol between them. - -**`Scroll` is `ScrollArea`**, because it only scrolls a predefined area -- -your word for it. **`ScrollController`** (`widget/position/scrollable.rs`) -holds everything that is not a particular widget's layout: the position, -the pending delta, the travel left each way, the pin, the `DragGesture` -and the `Flinger`. **`Scrollable`** is the trait over it -- one required -pair of methods handing the controller back, and `scroll`, `fling`, `drag`, -`amt`, `is_scrolling`, `cancel_fling`, `tick_fling` and the pin as -defaults. - -**The three scrolling methods are off `Widget`.** `scrolls_itself`, -`apply_scroll` and `scroll_offset` existed only so a `Scroll` could drive a -`LazySpan` it had no business wrapping. A `LazySpan` owns its own -controller now, so there is no wrapper, no measure/apply/place dance -between two widgets, and no `amt` with two meanings depending on which kind -of child it had. The transcript's tree lost a node with it: `list` is the -layout *and* the position. - - before Masked(Scroll(LazySpan)) .scrollable_to_end(Axis::Y) - after Masked(LazySpan) .scrollable() - -**`.scrollable(axis, pin)`** is the only one now -- `scrollable`, -`scrollable_on` and `scrollable_to_end` were one mechanism with the -arguments hidden in the names. A `LazySpan` has an **inherent** -`scrollable()` that shadows it, since Rust resolves inherent methods -first: same word at the call site, and the wrapping version cannot reach a -widget that must not be wrapped. The axis and pin are already its own. - -**`Pin` says which end either way round.** `Start`/`End` are -content-relative, `Neg`/`Pos` axis-absolute -- your ask, so a caller can -say "the bottom" and mean it whichever way the content runs. They coincide -for everything except a reversed `LazySpan`, where they are opposites. - -**A delta's sign is now a screen direction**, positive scrolling up or -left. It was "positive brings earlier content into view", which points the -opposite way for a `Dir::UP` span -- a real defect, latent only because -nothing builds one yet, and invisible to the existing test because that -test asserts in the same space the bug lives in. - -**Why overscroll exists at all**, since you asked: a lazy span cannot see -the wall until it has walked to it, so with rows loaded past an edge it -honestly reports infinite travel, takes the whole delta, and the walk -finds the content ran out 200px ago. It is given back inside the same -frame. The rows past the edge have never been measured, and measuring them -is the work virtualisation exists to skip. - -**One behaviour changed**: a delta is applied by the next `draw` rather -than the moment it arrives, since the layout is the only thing that knows -where the content ends. Nothing on screen differs -- input is followed by -a frame -- but `amt` no longer moves between draws, which several tests -were reading. - -The pin question `SCROLL.md` had open ("the pin lives in each widget, not -in `Scroll`") is closed by this: it is one field on the controller, and a -caller edits one place. - -## 2026-09-08 (earlier): `List` is `LazySpan`, and scrolling belongs to `Scroll` - -From the design exchange after the overscroll fix, where you asked -whether `List` could just be `Span::scrollable()`. It cannot -- a lazy -layout is a real thing a `Span` is not, for reasons measured below -- but -almost everything you named as out of place was, and it has all moved. - -**`List` -> `LazySpan`** (`ListRow` -> `LazyItem`, `RowKey` unchanged), -living beside `Span` under `widget/position/`. It is what `Span` is, laid -out lazily from an anchor rather than eagerly from the start, and the name -says so. It also stops colliding with `BlockKind::List` in the markdown -code. - -**It takes a `Dir` instead of an `Axis`**, meaning what it means in `Span`: -which end item 0 sits at. That is a **different question from which end -the view is pinned to**, and conflating them would stand a transcript on -its head -- its oldest message is item 0 and sits at the top (`Dir::DOWN`) -while the view clings to the bottom. So the pin is its own argument: -`LazySpan::new(dir, at_end)`, spelled like `Scroll::new`'s. `Dir::UP` is -real rather than nominal: the walk works in direction-relative pixels from -the leading edge, with `abs_region` flipping the box and `flip_pos` -converting the screen-space positions the hit-testing helpers speak in. - -**Everything about scrolling left the list.** Its `Flinger`, its -`density`, its `Arc` (which had no business existing in -a single-threaded frame loop), its `tick`, and the whole -`fling`/`cancel_fling`/`tick_fling`/`is_scrolling`/`fling_velocity` -surface are gone. `Scroll` was the only other `Flinger` user, so there is -now exactly one implementation of the physics and `sense.rs` keeps the -parts both ever shared. A transcript is `list.scrollable_to_end()` like -anything else. - -### The new public surface: three `Widget` methods - -```rust -fn scrolls_itself(&self) -> bool { false } -fn apply_scroll(&mut self, delta: &mut f32) {} -fn scroll_offset(&self) -> f32 { 0.0 } -``` - -`Scroll` asks the first, and if the child says yes it stops sliding the -child about as a lump and starts handing it deltas. Each method is `&self` -or `&mut self` for a reason worth keeping: reaching a widget through -`Widgets::get_dyn_mut` *marks it dirty*, so asking the capability question -through `apply_scroll` would dirty every ordinary child on every scroll -tick and cost exactly the O(1) move the whole scheme exists for. - -`Scroll::draw` is then measure, apply, place -- the same measure-then-place -idiom it already used for its own content length. The measuring draw is -free in the common case (unchanged region, nothing dirty, so `draw_inner` -returns immediately and the child's stored walls are still correct) and -really walks exactly when the content changed, which is when they need -re-reading. **Nothing is marked by hand**: reaching the child to hand it -the delta is itself what dirties it, so the placing draw really draws. -That is why `Painter::draw_again` could stay deleted. - -### Why `scroll_offset` exists - -`apply_scroll` leaving a remainder was meant to be the whole story, and it -is not quite. A lazy span usually **cannot say where its content ends -until it has walked there**, so it takes a delta in full whenever the wall -is not already in view, and the walk that follows gives part of it back. -The remainder is exact only when the wall was already visible. `Scroll` -adding remainders up would over-count by every overshoot and never -correct, so it reads the child's accumulated movement after the placing -draw instead, and `amt` is set from that. `amt` therefore always equals -what is on screen. - -For a self-positioning child `amt` is **movement, not position**: paging -rows in above moves the origin and the child cannot say by how much, -never having measured them. The direction is the same as an ordinary -child's; the absolute value is not comparable, and a scrollbar would need -a real content length before it could use either. - -### One convention for a scroll delta - -There were two, and they read alike: `Scroll::scroll(+)` moved toward the -*start* while `LazySpan::scroll(+)` moved toward the *end*, with the -latter's doc claiming to mirror the former. Every call site had to -remember which it was talking to, and `Selection::drag` negated on the way -in. There is one now -- the finger's, which is `Scroll`'s -- and -`LazySpan::scroll` is private with the single negation inside -`apply_scroll`. `a_negative_delta_moves_toward_the_end` pins it across the -whole handoff, since no type can catch a scroll running backwards. - -### What the measurements said, for the record - -- A `Span` is skipped entirely in the steady state (`(0,0,0)` counters), - but **when it is redrawn it costs two draws per child** -- 21 draws for - 10 children -- because phase 1 offers each child the ambient region to - learn its length and phase 2 offers it its real share. Any mutation of a - `Span` therefore redraws all of it: 24 draws for 11 children after one - prepend. That is why a transcript cannot be one. -- A settled scroll tick of the lazy span with 31 rows on screen is - **1 real draw and 31 move-slot writes**, no primitive rewrites and no - text reshaped; an idle frame is `(0,0,0,0)`. That is the number against - which "store the edges and only recompute what changed" would be - judged, and it is why the walk was left alone. -- The framework's own `ActiveData::size` cannot serve as the row-height - cache: `remove_rec` frees it the moment a row is virtualised away, - which is exactly when the walk needs it. The cache stays in the - container, keyed by `RowKey` -- which is also right for the reason you - gave, that a widget may one day render in two places and a size keyed - by `WidgetId` would break. - -## 2026-09-08 (later still): a `List` clamps its overscroll in the same frame, and `draw_again` is gone - -The last place in iris that corrected itself on a later frame. `List`'s -walk outward from its anchor could end up off the end of its content -- -a fling stops wherever the spline's last step left it, and a `scroll` is -deliberately unclamped because nothing at the moment of the call knows -where the content ends. `clamp_to_content` measured that gap from the -edges the walk had just placed, wrote it to the anchor, and asked for -another frame. So one frame was drawn with the content past its own end, -and on Iris's phone a hard fling to the top left the whole screen blank -until something asked for that frame -- which a fling that has stopped no -longer does. - -It is the same shape as `Scroll`'s fix. The walk is now `List::lay_out`, -and `List::draw` runs it, asks `overscroll_gap` whether the layout landed -off the end, and on a gap moves the anchor and runs the walk **again, -inside the same frame**. `overscroll_gap` is a pure measurement -- no -painter, no redraw handle -- and the decision to lay out again is `draw`'s. - -Three properties make the second pass cheap and correct: - -- **It runs only on a frame that actually overscrolled.** An ordinary - scroll tick still walks once. -- **One further pass always settles it.** The gap is measured from the - edges the first walk placed, so moving the anchor by it puts that edge - exactly on the viewport's; the opposite end can only open a new gap if - the content is shorter than the viewport, which `overscroll_gap` - declines to touch at all (a short list is bottom-anchored on purpose). -- **The second walk is mostly moves.** Every row keeps the box its cached - height gives it and only its offset changes, which is `draw_inner`'s - O(1) `mov` path. - -**Public surface: `Painter::draw_again` is removed.** `List` was its only -caller, so with this there is no "ask for a corrective frame" mechanism in -the framework -- which is the point, since reaching for one is the sign a -placement should have been redone inside the draw that discovered the -problem. - -`List::place` also lost half its body to the same simplification the rule -suggests: a placement is one pinned edge plus a height, so `Placement:: -edges(height)` gives the box and the top-known and bottom-known cases stop -being two copies of the same arithmetic. - -Tests that draw no settling frame on purpose, and fail without the change: -`fling_toward_the_start_stops_at_the_first_row` and the new -`scrolling_past_the_start_is_given_back_in_the_same_frame` in `list.rs`, -and `scrolling_past_the_first_row_settles_on_it` / -`scrolling_past_the_last_row_settles_on_it` at layer 1 -(`transcript-fixture/tests/top_edge.rs`). - -## 2026-09-08 (later): a `Scroll` measures and places its content in one frame - -Iris's phone: "when typing with the keyboard up and entering enough -newlines ... the text drops down close to the bottom and seems to ignore -the padding. If I close (and optionally reopen) the keyboard it seems to -fix itself." - -`Scroll::draw` used to place its child against **last** frame's content -length. Every newline therefore drew the field in a box one line short of -its text, and since that text is centred in its box it hung half a line -past each end -- putting the caret's line box a full 12dp below the bar's -inside edge, flush with its bottom, with the padding eaten. The comment -there said the lag "self-corrects the next frame". There was no next -frame: a keystroke dirties the field, not the scroll area, and after that -frame the tree is clean, so the stale placement was simply the last one -drawn -- until the keyboard closed, whose inset rewrite dirtied the bar -and forced the redraw. That is the "it fixes itself" half of the report. - -The rule Iris stated when she saw the first fix, and which the code now -follows: **layout is a pure function of the state, never of how many -frames have been drawn.** Nothing should heal itself, because nothing -should be drawn wrong in the first place; where two draws are genuinely -needed to place something, both happen in the same frame. - -So `Scroll::draw` now draws its child twice: once at last frame's length -purely to measure it, then once at the length it just measured, with the -end-pin and the clamp applied only to that second placement. The same -measure-then-place idiom `Span::draw` and `List::place` already use. -**The second draw is free unless the content's length actually changed** --- an ordinary scroll tick offers the same size at a new offset, so the -first call is `draw_inner`'s O(1) `mov` and the second, with an identical -region, returns at its first line. Growing a bottom-anchored area is -still O(1) in the sense that mattered; what it is not is free to place -its child against a length already known to be wrong. Last frame's length -survives only as a *hint* that keeps the common case cheap; nothing drawn -depends on it. - -Two consequences worth knowing: - -- **An end-anchored `Scroll` now sits at its end on its first drawn - frame**, not its second. It could not before: the end-pin needs the - content's length, which was a frame behind, so a fresh area showed its - start and jumped. Two layout tests that scrolled *down* from what they - assumed was the top now build their area with `at_end: false`, which is - what they always meant. -- **`List::clamp_to_content` is now the only place left that corrects on - the next frame** -- it finds a fling has run past the content's end and - marks itself for a redraw. Same defect, larger machinery; recorded in - docs/IRIS_TODO.md rather than folded into this change. - -Covered by `a_newline_leaves_the_caret_inside_the_composers_padding` -(layer 1, `transcript-fixture/tests/phone_screen.rs`), which draws no -settling frame on purpose and fails on the old code with the caret -exactly on the bar's edge. On the emulator the caret's bottom moved from -1535 -- the bar's own bottom edge -- to 1509, 26px inside a 31px padding; -the remainder is parley's line box standing ~6px taller than its line -height. `phone.rs` grew a `--typed TEXT` argument beside `--message`, -since laying the composer out from scratch and growing one already drawn -are different cases and only the second reproduces this. - -## 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 a review on 2026-09-06 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 (a review on 2026-09-06 - 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`). diff --git a/docs/IRIS_TODO.md b/docs/IRIS_TODO.md index fb8ba2f..41575f6 100644 --- a/docs/IRIS_TODO.md +++ b/docs/IRIS_TODO.md @@ -6,10 +6,9 @@ than rediscovering them. Each item says where it sits in the order and what "done" looks like. **Only open items live here.** An item is deleted when it lands, not -ticked -- what it changed belongs in `docs/IRIS.md` (the design log), and -a list of finished work is context every future session pays for. Fifty -closed items and six phone-report sections were removed on 2026-09-08 for -that reason. +ticked: a list of finished work is context every future session pays for, +and what a change did belongs at the code it changed. Fifty closed items +and six phone-report sections went on 2026-09-08 for that reason. ## Fix diff --git a/docs/LAYOUT.md b/docs/LAYOUT.md index a0a23a4..57cd932 100644 --- a/docs/LAYOUT.md +++ b/docs/LAYOUT.md @@ -16,8 +16,8 @@ in `render_state.rs` -- each is a real bug the first draft would have reproduced), and the two later additions that build on it. The pre-implementation framing -- what the old trait looked like, the checklist the design had to answer, the migration list, the pass conditions and the -"copy this into IRIS.md" note -- was deleted on 2026-09-08, having been -carried out; `docs/IRIS.md`'s 2026-09-04 entry is the public-API record. +"copy this into the design log" note -- was deleted on 2026-09-08, +having been carried out. ## Design diff --git a/docs/RUST.md b/docs/RUST.md index cf1647f..1315559 100644 --- a/docs/RUST.md +++ b/docs/RUST.md @@ -10,8 +10,8 @@ piece from scratch where it does not fit is fine; effort and elapsed time do not matter, long-term robustness does. **The framework question is closed.** Iris chose her own library, -[iris](https://github.com/cat16/iris), over Masonry on 2026-09-05 -(`DECISIONS.md`); the bake-off that got there, and the twelve experiments +[iris](https://github.com/cat16/iris), over Masonry on 2026-09-05. +The bake-off that got there, and the twelve experiments that proved it on a device, are summarised in "What the experiments settled" below rather than kept at length. What is left in this file is the plan for the rest of the app and the findings that outlive the tasks @@ -57,7 +57,7 @@ re-derived: measurements, dead ends, invariants and their reasons. at the end -- it is the layout everything else here assumes. - **Open across the rest of the docs**: `docs/IRIS_TODO.md` is iris's own list (streaming re-layout is the live one), `docs/TODO.md` is the Compose - app's, and `docs/DECISIONS.md` is the queue of choices for Iris to review. + app's. ## Desktop and phone share the code (Iris, 2026-09-07) @@ -358,7 +358,7 @@ needs its reasoning, the reasoning is at the thing itself. posts a real notification and receives a real share, and `cargo xtask apk` packages an installable APK with `javac`/`d8`/`aapt2`/`zipalign`/ `apksigner` and one disclosed Gradle call, documented at - `xtask/src/apk.rs`'s module doc. + `scripts/xtask/src/apk.rs`'s module doc. - **E4 -- the same screen on the desktop**, which is now `app-rust`'s `src/desktop` and the `ai-app-desktop` binary. @@ -516,13 +516,13 @@ device. (`ui::markdown::BlockFrame`) — plain, verbatim, quote — with fences and tables verbatim, headings scaled, and inline styling per span. `app-rust/src/ui/markdown.rs` is the code - and `docs/IRIS.md`'s 2026-09-06 entry the design. + and its module doc the design. - [x] **P1b — tool-call cards and grouping.** Done 2026-09-06. `ToolRows.kt`/`ToolInput.kt` ported to `app-rust/src/ui/tool.rs`: a run of calls is one collapsible group, each card carries its state and summary, and the five `ToolState` values each have their own appearance. - `docs/DECISIONS.md`'s 2026-09-06 entries have what was chosen. + `tool.rs`'s module doc has what was chosen. - [ ] **P1c — history paging and jump-to-latest.** Wire `client::transcript_source` into `src/ui`: the opening page, paging back on scroll with the cushion @@ -599,13 +599,12 @@ device. paged). Re-take the I5 `FrameReport` (`iris frame report` in logcat, same as I5's box) once this screen has real paging and compare it against I5's own numbers, not against Compose's — the - three measurement sources still are not comparable per - `DECISIONS.md`'s DEFERRED item. + three measurement sources still are not comparable. - [ ] **P2 — the shell merge and a real phone install.** Merge this screen's cdylib into the E3/E5 shell (`src/shell` + `app/shellApp`) behind the same feature-flag pattern I5 used to - extend `src/android` (`DECISIONS.md`, 2026-09-05), so there is + extend `src/android` (decided 2026-09-05), so there is one app — notification service, share target and the real screen — rather than a demo shell and a service shell side by side. Package with `cargo xtask apk` (E5) and get it onto the real GrapheneOS @@ -753,7 +752,7 @@ push, in the session that picked the task up. `main` and not in `ai-app`. Nothing on this branch is production until Iris says so. Commit and push as you go. 3. The E- and I-steps (the framework decision) are done — iris won, - `DECISIONS.md` 2026-09-05. Take the next unchecked P-box in "## The + decided 2026-09-05. Take the next unchecked P-box in "## The port, in order (decided 2026-09-05)"; **P1 — session screen parity — is next.** 4. Every step ends with its measurement written into this file beside the @@ -764,7 +763,7 @@ push, in the session that picked the task up. for a server with fixtures, `transcript-bench.sh` for the scroll baseline, `ui-trace` for anything positional, `emu up` for the emulator, `iris/run-headless.sh EXAMPLE --shot PNG` for an iris - example on this displayless machine, and `rigs/gpu-probe` to ask a + example on this displayless machine, and `scripts/rigs/gpu-probe` to ask a device (this VM, the emulator, or a real phone over `adb push`) what `wgpu` features and limits it actually has before building anything on the assumption it does. The Vulkan section below says how to get a @@ -810,7 +809,7 @@ Nine cargo workspaces, each with its own `Cargo.lock` and `target/`, and the port's project code in five places — `iris/transcript-ui`, `iris/transcript-fixture`, `iris/desktop-app`, `iris/android-app` (all *inside* the framework), plus `client-core` and `android-shell` at the -root. Two root markdown files (`DECISIONS.md`, `SUBAGENTS.md`) sat outside +root. Two root markdown files sat outside `docs/`. ### What it is @@ -896,7 +895,7 @@ So: three workspaces where there were nine — `event-model`, `server`, ### Verified -`./run-tests.sh` (event-model, server, app-rust) and `cd iris && cargo +`./scripts/run-tests.sh` (event-model, server, app-rust) and `cd iris && cargo test` green; `cargo clippy --all-targets` and `cargo fmt` clean in every workspace. `cargo ndk -t x86_64` links `libai_app.so`; `./build-apk.sh debug --abi x86_64` produces an installable APK; installed and launched on diff --git a/docs/SCROLL.md b/docs/SCROLL.md index 0f8a666..73275e4 100644 --- a/docs/SCROLL.md +++ b/docs/SCROLL.md @@ -1,7 +1,7 @@ # Scrolling in iris How anything in iris scrolls, as of 2026-09-08. This is the current -design, not a history — `docs/IRIS.md`'s dated entries have the account of +design, not a history — the git log has the account of how it got here, and `docs/IRIS_TODO.md` has what is still open. Read this before touching `iris/src/widget/position/scrollable.rs`, diff --git a/docs/SUBAGENTS.md b/docs/SUBAGENTS.md index 0957ca5..c396b89 100644 --- a/docs/SUBAGENTS.md +++ b/docs/SUBAGENTS.md @@ -3,7 +3,7 @@ A session's subagents -- the helpers a Claude Code session starts through its Task tool -- each get a transcript of their own, listed under the session's card and readable in the same transcript view the session has. Designed -2026-09-05; the decisions Bryan has not yet reviewed are in `SUBAGENTS_DECISIONS.md`. +2026-09-05; the choices behind it are at the bottom of this file. ## What a subagent is here @@ -139,3 +139,43 @@ The per-subagent status is only read when the list route is asked for. cache take a transcript address rather than a session id -- `sessions/{id}` or `sessions/{id}/subagents/{sub}` -- so the cache nests a subagent's copy under its session's and the same code serves both. + +## The choices behind the shape, and what they rejected + + +Made without asking, each with limited blast radius: + +1. **A subagent is a transcript, not a session.** It has no process, + controls or settings; it is addressed as `/sessions/{id}/subagents/{sub}` + and stored under the session's directory, so deleting the session takes + it. Alternative rejected: registering it as a session of its own, which + would give it a card in the main list and a driver that can do nothing. +2. **Read-only view is the session screen minus its controls**, rather than + a second, simpler transcript screen. Keeps paging, caching, selection + and rendering in one place. Cost: a `readOnly` mode threaded through + `SessionScreen`. +3. **The list only carries a count.** Each session row says how many + subagents it has; their titles and statuses are fetched when the card is + expanded. Keeps `GET /sessions` from reading every subagent transcript. + Consequence: an expanded card's statuses refresh with the list, not live. +4. **Expanded/collapsed is remembered per session on the phone**, not on + the server. Collapsed by default, per the transcript convention that new + things arrive collapsed. +5. **Subagents of imported sessions are not shown.** The import path still + skips `isSidechain` records; the CLI's own `subagents/agent-*.jsonl` files + are not read. Only subagents run while this backend was watching exist. +6. **Echo grows `/subagent [n]`** as the test rig, so nothing here needs a + paid turn to exercise. + +Deferred, because they reach further than this feature: + +- **Live status on the list.** Whether the session list should follow a + stream at all (it refreshes on demand today) decides whether subagent + status can ever be live there. Not changed. +- **Nested subagents.** A subagent's own Task calls are shown as tool calls + in its transcript and are not given transcripts of their own. Supporting + that is the same mechanism one level down, but the UI would need nested + expanders. + +- **The subagent status row says "context unknown".** Nothing measures a + subagent's context; the row could leave it out rather than admit it. diff --git a/docs/SUBAGENTS_DECISIONS.md b/docs/SUBAGENTS_DECISIONS.md deleted file mode 100644 index 65ff286..0000000 --- a/docs/SUBAGENTS_DECISIONS.md +++ /dev/null @@ -1,44 +0,0 @@ -# Decisions awaiting review - -Choices made while working autonomously, for Bryan to keep or change. Each -says what was picked and why; the detail is in the design doc it names. -Delete an entry once it has been looked at. - -## Subagent views (2026-09-05, `SUBAGENTS.md`) - -Made on my own judgement, limited blast radius: - -1. **A subagent is a transcript, not a session.** It has no process, - controls or settings; it is addressed as `/sessions/{id}/subagents/{sub}` - and stored under the session's directory, so deleting the session takes - it. Alternative rejected: registering it as a session of its own, which - would give it a card in the main list and a driver that can do nothing. -2. **Read-only view is the session screen minus its controls**, rather than - a second, simpler transcript screen. Keeps paging, caching, selection - and rendering in one place. Cost: a `readOnly` mode threaded through - `SessionScreen`. -3. **The list only carries a count.** Each session row says how many - subagents it has; their titles and statuses are fetched when the card is - expanded. Keeps `GET /sessions` from reading every subagent transcript. - Consequence: an expanded card's statuses refresh with the list, not live. -4. **Expanded/collapsed is remembered per session on the phone**, not on - the server. Collapsed by default, per the transcript convention that new - things arrive collapsed. -5. **Subagents of imported sessions are not shown.** The import path still - skips `isSidechain` records; the CLI's own `subagents/agent-*.jsonl` files - are not read. Only subagents run while this backend was watching exist. -6. **Echo grows `/subagent [n]`** as the test rig, so nothing here needs a - paid turn to exercise. - -Deferred, because they reach further than this feature: - -- **Live status on the list.** Whether the session list should follow a - stream at all (it refreshes on demand today) decides whether subagent - status can ever be live there. Not changed. -- **Nested subagents.** A subagent's own Task calls are shown as tool calls - in its transcript and are not given transcripts of their own. Supporting - that is the same mechanism one level down, but the UI would need nested - expanders. - -- **The subagent status row says "context unknown".** Nothing measures a - subagent's context; the row could leave it out rather than admit it. diff --git a/docs/TEXTURES.md b/docs/TEXTURES.md index e6eadd7..d230224 100644 --- a/docs/TEXTURES.md +++ b/docs/TEXTURES.md @@ -34,7 +34,7 @@ case that grows this array without bound: each attachment becomes its own ## What was measured -**A new rig, `rigs/gpu-probe`**, asks a device for exactly iris's features +**A new rig, `scripts/rigs/gpu-probe`**, asks a device for exactly iris's features and limits with no window and no APK — a plain executable pushed with `adb push` and run from `/data/local/tmp`. It has two parts: `wgpu::Adapter::request_device` with iris's exact `Features`/`Limits` @@ -99,7 +99,7 @@ literally fixed. Files: `core/src/primitive/texture.rs` (`Textures`, `core/src/render/atlas.rs`, `core/src/ui/painter.rs`, `core/src/render/mod.rs` (`UiRenderNode`, `UiLimits` removed), `core/src/render/shader.wgsl`, `src/default/render.rs`, and -`rigs/gpu-probe/src/main.rs`. +`scripts/rigs/gpu-probe/src/main.rs`. **1. Atlas pages as array layers.** `GpuTextures` owns one `texture_2d_array` (`array_texture`/`array_view`), grown by doubling @@ -215,7 +215,7 @@ list's ordering would have to be reconciled. 1024px page holding only a handful of glyphs at a time would be mostly wasted space in normal use. Confirmed the revert left `tabs` and `minimal` byte-identical to the pre-check screenshots afterward. -- **The decisive check**, `rigs/gpu-probe` rewritten to request iris's new +- **The decisive check**, `scripts/rigs/gpu-probe` rewritten to request iris's new (empty) feature/limit set and run on this checkout's own emulator (`ai-app-2`, via `emu`), booted with `EMU_GPU=software` so the guest gets a real Vulkan device (SwiftShader) rather than the `-gpu host` default, @@ -225,7 +225,7 @@ list's ordering would have to be reconciled. at all, which reads exactly like the old bindless failure if you don't know to ask for `EMU_GPU=software`): - cd rigs/gpu-probe + cd scripts/rigs/gpu-probe ANDROID_NDK_HOME=$HOME/Android/Sdk/ndk/29.0.14206865 \ cargo ndk -t arm64-v8a -P 26 build --release EMU_GPU=software emu up # from ~/repos/emulator-tools diff --git a/iris/core/src/icon.rs b/iris/core/src/icon.rs index b26e833..e9f5f29 100644 --- a/iris/core/src/icon.rs +++ b/iris/core/src/icon.rs @@ -2,7 +2,7 @@ //! //! **Why a bundled font rather than ordinary Unicode**: the disclosure //! mark used to be U+25B8/25BE/25B4 out of whatever face the platform -//! resolved, and once iris stopped bundling fonts (DECISIONS.md, +//! resolved, and once iris stopped bundling fonts (decided //! 2026-09-07) Iris's phone drew an empty box for them and this VM drew a //! dot. UI_RULES' answer is not to avoid glyphs but to ship them, which is //! also what the Compose app has always done for its icons diff --git a/iris/core/src/primitive/text.rs b/iris/core/src/primitive/text.rs index 1bc24e7..74da0e1 100644 --- a/iris/core/src/primitive/text.rs +++ b/iris/core/src/primitive/text.rs @@ -18,7 +18,7 @@ use swash::{ /// /// This is the one font bundled here, and it is not a text font: body and /// monospace text still come from the platform's own collection -/// (DECISIONS.md, 2026-09-07). An icon is the opposite case -- a small, +/// (decided 2026-09-07). An icon is the opposite case -- a small, /// closed set of codepoints no system font is guaranteed to have -- which /// is the same division the Compose app makes. const NERD_ICONS: &[u8] = include_bytes!("../../assets/fonts/nerd_icons.ttf"); @@ -92,7 +92,7 @@ impl Default for TextData { /// `SansSerif`/`SystemUi` to `["Roboto Flex", "Roboto", "Noto Sans"]` /// and `Monospace` to the platform's `"monospace"` alias; the desktop /// build's backend is fontconfig. No font is bundled or registered - /// here -- see DECISIONS.md's 2026-09-07 entry for why (matching what + /// here -- see the 2026-09-07 decision for why (matching what /// the Compose app does: it takes body/monospace text from /// `FontFamily.Default`/`FontFamily.Monospace`, i.e. Android's Roboto /// and its platform monospace face, and ships no text font of its own, @@ -137,7 +137,7 @@ fn register_icon_font(font_cx: &mut FontContext) -> Option { /// `GenericFamily::Monospace` (confirmed against /// `fontique-0.11.1/src/backend/android.rs`'s `SystemFonts::new`, and still /// present on `linebender/parley`'s `main` as of 2026-09-07, so there is no -/// released fix to bump to yet -- see DECISIONS.md's 2026-09-07 entry, +/// released fix to bump to yet -- see the 2026-09-07 decision, /// "Platform fonts," for the full account). Two bugs stack, not one: /// `DEFAULT_GENERIC_FAMILIES` looks up the name `"monospace"` *before* /// `fonts.xml` is parsed into that same name map, and even after parsing, diff --git a/iris/examples/message_list.rs b/iris/examples/message_list.rs index 4b63859..bc14e86 100644 --- a/iris/examples/message_list.rs +++ b/iris/examples/message_list.rs @@ -2,7 +2,7 @@ //! wrapped text, one in twelve carrying a small image, scrollable with the //! mouse wheel. Run headless with `iris/run-headless.sh message_list --shot //! /tmp/message_list.png` -- there is no display on this machine, so that -//! is the only way to see it rendered; `run-tests.sh`/`cargo test` never +//! is the only way to see it rendered; `scripts/run-tests.sh`/`cargo test` never //! touch this file. //! //! Rows alternate two background tints so a screenshot can show the diff --git a/iris/src/sense.rs b/iris/src/sense.rs index 2f3f8cf..bc43b82 100644 --- a/iris/src/sense.rs +++ b/iris/src/sense.rs @@ -1191,7 +1191,7 @@ pub enum GestureOutcome { /// terminal release -- keeps reaching the same widget even after the /// finger has moved off whatever hit region first noticed the press. Iris /// asked for this to live here rather than in `transcript-ui::Selection` -/// (2026-09-06, recorded in `IRIS.md`): "dragging should be part of the +/// (2026-09-06): "dragging should be part of the /// default input system ... anything that provides good performance and /// can be generalized well is part of iris rather than the app." A caller /// still decides what a committed pan or a completed selection *means* diff --git a/iris/tests/mask_sdf.rs b/iris/tests/mask_sdf.rs index 11a4670..9c8d615 100644 --- a/iris/tests/mask_sdf.rs +++ b/iris/tests/mask_sdf.rs @@ -283,7 +283,7 @@ fn run_shader(gpu: &Gpu, radius: f32) -> Vec { /// longer mapped. libtest runs every `#[test]` on a spawned thread, so a /// test that opens and closes an instance segfaults *after* printing its /// result, which reads exactly like the test failing. Measured -/// 2026-09-08 with `rigs/gpu-probe`'s `teardown` bin: it +/// 2026-09-08 with `scripts/rigs/gpu-probe`'s `teardown` bin: it /// needs no wgpu (raw `ash` does it too), no GPU work, and no device -- /// an instance created and destroyed on a spawned thread is enough, and /// keeping any one instance alive is enough to prevent it. diff --git a/rigs/gpu-probe/.gitignore b/scripts/rigs/gpu-probe/.gitignore similarity index 100% rename from rigs/gpu-probe/.gitignore rename to scripts/rigs/gpu-probe/.gitignore diff --git a/rigs/gpu-probe/Cargo.lock b/scripts/rigs/gpu-probe/Cargo.lock similarity index 100% rename from rigs/gpu-probe/Cargo.lock rename to scripts/rigs/gpu-probe/Cargo.lock diff --git a/rigs/gpu-probe/Cargo.toml b/scripts/rigs/gpu-probe/Cargo.toml similarity index 100% rename from rigs/gpu-probe/Cargo.toml rename to scripts/rigs/gpu-probe/Cargo.toml diff --git a/rigs/gpu-probe/src/bin/teardown.rs b/scripts/rigs/gpu-probe/src/bin/teardown.rs similarity index 100% rename from rigs/gpu-probe/src/bin/teardown.rs rename to scripts/rigs/gpu-probe/src/bin/teardown.rs diff --git a/rigs/gpu-probe/src/main.rs b/scripts/rigs/gpu-probe/src/main.rs similarity index 100% rename from rigs/gpu-probe/src/main.rs rename to scripts/rigs/gpu-probe/src/main.rs diff --git a/rigs/gpu-probe/src/vk.rs b/scripts/rigs/gpu-probe/src/vk.rs similarity index 100% rename from rigs/gpu-probe/src/vk.rs rename to scripts/rigs/gpu-probe/src/vk.rs diff --git a/rigs/virtgpu-probe/virtgpu-probe.c b/scripts/rigs/virtgpu-probe/virtgpu-probe.c similarity index 100% rename from rigs/virtgpu-probe/virtgpu-probe.c rename to scripts/rigs/virtgpu-probe/virtgpu-probe.c diff --git a/run-tests.sh b/scripts/run-tests.sh similarity index 90% rename from run-tests.sh rename to scripts/run-tests.sh index dfdeaa4..29164d1 100755 --- a/run-tests.sh +++ b/scripts/run-tests.sh @@ -1,6 +1,6 @@ #!/bin/sh # Runs this repo's Rust tests. Extra arguments are forwarded to each -# `cargo test`, e.g. `./run-tests.sh transcript` to run just the transcript +# `cargo test`, e.g. `scripts/run-tests.sh transcript` to run just the transcript # tests in every workspace. # # Three workspaces, in dependency order: @@ -19,7 +19,7 @@ # (`cd iris && cargo test`). They are not in this loop because iris is not # about this product and its suite is the slower of the two. set -eu -cd "$(dirname "$0")" +cd "$(dirname "$0")/.." for workspace in event-model server app-rust; do (cd "$workspace" && cargo test "$@") done diff --git a/test-wg-tunnel.sh b/scripts/test-wg-tunnel.sh similarity index 93% rename from test-wg-tunnel.sh rename to scripts/test-wg-tunnel.sh index e0e2fd4..2fc9a37 100755 --- a/test-wg-tunnel.sh +++ b/scripts/test-wg-tunnel.sh @@ -17,9 +17,9 @@ # (certs.rs covers every local address), so a client inside the tunnel # completes the same pinned-TLS handshake a phone will. # -# ./test-wg-tunnel.sh up create the tunnel (needs sudo) -# ./test-wg-tunnel.sh test run the server on wg0 and reach it from "phone" -# ./test-wg-tunnel.sh down remove everything it created +# scripts/test-wg-tunnel.sh up create the tunnel (needs sudo) +# scripts/test-wg-tunnel.sh test run the server on wg0 and reach it from "phone" +# scripts/test-wg-tunnel.sh down remove everything it created # # Everything here is torn down by `down`: the netns (taking wg1 and the veth # peer with it), wg0, and the temporary key files. @@ -35,7 +35,8 @@ SERVER_UDP_IP=10.99.0.1 CLIENT_UDP_IP=10.99.0.2 LISTEN_PORT=51820 KEYDIR=/run/ai-app-wg-test -REPO=$(cd "$(dirname "$0")" && pwd) +# This script lives in scripts/; everything it names is under the root. +REPO=$(cd "$(dirname "$0")/.." && pwd) up() { echo "==> Generating ephemeral keypairs in $KEYDIR" diff --git a/wg-setup-host.sh b/scripts/wg-setup-host.sh similarity index 95% rename from wg-setup-host.sh rename to scripts/wg-setup-host.sh index 92f772d..e87f8ff 100755 --- a/wg-setup-host.sh +++ b/scripts/wg-setup-host.sh @@ -4,7 +4,7 @@ # dev VM (the VM is behind qemu user-mode networking and has no inbound path; # see AGENTS.md). # -# sudo WG_ENDPOINT=your-name.duckdns.org ./wg-setup-host.sh +# sudo WG_ENDPOINT=your-name.duckdns.org scripts/wg-setup-host.sh # # What it creates: # /etc/wireguard/wg0.conf the backend's tunnel: 10.66.0.1, port 51820 @@ -75,7 +75,7 @@ fi echo "==> Writing $WG_DIR/wg0.conf" cat > "$WG_DIR/wg0.conf" < Result { sdk::require_ndk_installed(&sdk.root)?; require_cargo_ndk()?; - let out_dir = repo_root.join("target").join("xtask").join("apk"); + // xtask's own intermediate files, in its own crate's target/ rather + // than a `target/` at the repo root -- there is no workspace there and + // a build directory in the root is not something anybody was looking + // for (Iris, 2026-09-09). + let out_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join("apk"); std::fs::create_dir_all(&out_dir).map_err(|e| { Fail::new( "could not create the xtask output directory", &e.to_string(), - "check permissions under target/", + "check permissions under scripts/xtask/target/", ) })?; @@ -98,13 +104,18 @@ pub fn build(variant: Variant, abis: &[String]) -> Result { // `APK_PATTERNS`, which has no per-component path override) finds it // without needing a change on that side -- `.dev-updater.ron`'s // `shell` component points its `cwd` here. The working files above - // stay under `target/xtask/apk/`, an ordinary build-cache location. - let published_dir = repo_root.join("xtask/build/outputs/apk").join(variant_name); + // stay under `scripts/xtask/target/apk/`, an ordinary build-cache location. + // `scripts/build/...`, not `scripts/xtask/build/...`: Dev Updater + // discovers APKs with `*/build/outputs/apk/*/*.apk` from the checkout + // root, which is exactly one directory deep. See `.dev-updater.ron`. + let published_dir = repo_root + .join("scripts/build/outputs/apk") + .join(variant_name); std::fs::create_dir_all(&published_dir).map_err(|e| { Fail::new( "could not create the published APK directory", &e.to_string(), - "check permissions under xtask/build", + "check permissions under scripts/build", ) })?; let published_apk = published_dir.join(format!("ai-app-shell-{variant_name}.apk")); @@ -112,7 +123,7 @@ pub fn build(variant: Variant, abis: &[String]) -> Result { Fail::new( "could not publish the signed APK", &e.to_string(), - "check permissions under xtask/build", + "check permissions under scripts/build", ) })?; @@ -120,15 +131,20 @@ pub fn build(variant: Variant, abis: &[String]) -> Result { } fn repo_root() -> Result { - // xtask's own Cargo.toml is at /xtask/Cargo.toml. + // xtask's own Cargo.toml is at /scripts/xtask/Cargo.toml, + // so the root is two levels up (2026-09-09: it used to be one). let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - manifest_dir.parent().map(Path::to_path_buf).ok_or_else(|| { - Fail::new( - "could not find the repo root", - "CARGO_MANIFEST_DIR has no parent", - "run through cargo, not by hand", - ) - }) + manifest_dir + .parent() + .and_then(Path::parent) + .map(Path::to_path_buf) + .ok_or_else(|| { + Fail::new( + "could not find the repo root", + "CARGO_MANIFEST_DIR has no grandparent", + "run through cargo, not by hand", + ) + }) } fn require_cargo_ndk() -> Result<(), Fail> { @@ -251,7 +267,7 @@ fn compile_java( Fail::new( "could not create the generated-sources directory", &e.to_string(), - "check permissions under target/", + "check permissions under scripts/xtask/target/", ) })?; // Same shape as shellApp's Gradle `generatePinnedCa` task: the text @@ -266,7 +282,7 @@ fn compile_java( Fail::new( "could not write PinnedCa.java", &e.to_string(), - "check permissions under target/", + "check permissions under scripts/xtask/target/", ) })?; @@ -275,7 +291,7 @@ fn compile_java( Fail::new( "could not create the classes directory", &e.to_string(), - "check permissions under target/", + "check permissions under scripts/xtask/target/", ) })?; @@ -313,7 +329,7 @@ fn dex( Fail::new( "could not create the dex output directory", &e.to_string(), - "check permissions under target/", + "check permissions under scripts/xtask/target/", ) })?; let mut cmd = Command::new(sdk.tool("d8")); @@ -343,7 +359,7 @@ fn aapt2_link(sdk: &Sdk, shell_app_dir: &Path, base_apk: &Path) -> Result<(), Fa return Err(Fail::new( "app/shellApp's manifest already has a package attribute", "aapt2_link() assumes it doesn't and injects one", - "update aapt2_link() in xtask/src/apk.rs to stop injecting a second one", + "update aapt2_link() in scripts/xtask/src/apk.rs to stop injecting a second one", )); } let merged_manifest = manifest_text.replacen( @@ -356,7 +372,7 @@ fn aapt2_link(sdk: &Sdk, shell_app_dir: &Path, base_apk: &Path) -> Result<(), Fa Fail::new( "could not write the merged manifest", &e.to_string(), - "check permissions under target/", + "check permissions under scripts/xtask/target/", ) })?; @@ -381,7 +397,7 @@ fn merge( Fail::new( "could not copy the base APK", &e.to_string(), - "check permissions under target/", + "check permissions under scripts/xtask/target/", ) })?; @@ -422,14 +438,14 @@ fn merge( Fail::new( "could not stage the native library", &e.to_string(), - "check permissions under target/", + "check permissions under scripts/xtask/target/", ) })?; std::fs::copy(&src, dest_dir.join(so_name)).map_err(|e| { Fail::new( "could not stage the native library", &e.to_string(), - "check permissions under target/", + "check permissions under scripts/xtask/target/", ) })?; } diff --git a/xtask/src/keystore.rs b/scripts/xtask/src/keystore.rs similarity index 100% rename from xtask/src/keystore.rs rename to scripts/xtask/src/keystore.rs diff --git a/xtask/src/main.rs b/scripts/xtask/src/main.rs similarity index 100% rename from xtask/src/main.rs rename to scripts/xtask/src/main.rs diff --git a/xtask/src/sdk.rs b/scripts/xtask/src/sdk.rs similarity index 100% rename from xtask/src/sdk.rs rename to scripts/xtask/src/sdk.rs