# Handoff Where the Iris retained-layout work stands for a worker picking it up cold. This file contains current decisions, the implementation plan and its checks. The durable layout design and measurement method are in `docs/LAYOUT.md`. The temporary investigation record is in `docs/LAYOUT_LOG.md`; delete that log when the one-ask protocol lands, after moving any fact that must survive. ## Where things stand Canonical upstream Iris `main` is **`ca2b4b2`** (#17, the headless rig). PR #18's pushed branch is `split/18-position-chain` at **`e44dea3`**. Its detached comparison checkout is `/home/bob/repos/iris-layout-baseline`. It is the reviewed baseline this work must preserve or improve. The continuation is `/home/bob/repos/iris-layout-experiment`, now on branch **`wip/one-ask`** at **`3091fb8`**, one commit over `4328eac` (the head of `wip/transparent-frames`, which is unchanged). It replaces the step 3 plan below with a smaller protocol change, and it passes every fast check: | check at `3091fb8` | result | | --- | --- | | `cargo fmt --all --check`, clippy `-D warnings`, with and without `layout-diagnostics` | clean | | `cargo test --workspace` (debug) | 114 suite, 20 core, 11 generated, all green | | the two decided-box pins from step 1 | **pass** (red at `4328eac`) | | `cargo test --release --test generated` | 11/11 | | shrinker, 400 seeds, depth 5, all fifteen cases | agree, 56 s | | 1000 seeds at depth 6, and the 2000-seed depth-4 scan | see `docs/LAYOUT_LOG.md`; they were running when this was written | The worker's uncommitted step 3/4 experiment is preserved as branch `wip/step3-experiment` (one commit over `4328eac`) and as `~/repos/iris-step3-experiment.patch`. It is evidence, not the protocol. The app's Iris pin is unchanged. ## The two rules to protect These outrank the accumulated machinery: 1. A changed tree lays out exactly as if it had been drawn that way from the start. The warm/cold oracle and shrinker test this. 2. Lengths are predictable. `px` is that many pixels; `rel(0.5)` is half of the frame decided for the widget, wherever it sits; `leftover` is a share of the room left after every sibling's `px` and `rel` lengths are resolved. Do not fix a failure with a tolerance, another measurement flag, a special case in `Span`, or another layout method. ## What the previous plan got wrong The full account is in `docs/LAYOUT_LOG.md`. The short version, because it is the third plan for this repair and the next one should not repeat it: - **Every plan kept the second draw.** The old protocol drew a widget in the box it was asked in, then drew it *again* in the box its own answer placed it in whenever the first drawing's `Holds` did not cover that box. All the offer machinery -- `offer_place`, `offer_part`, `at_offer`, `measured()`, the local-redraw deferral -- existed to remember which of the two draws was the question. The plans tried to define that bit better; the defect was that there were two draws at all. - **The step 3 plan then over-corrected.** It said "every drawing must hold for the answer box it supplies", and the worker implemented exactly that as an assertion in `place`. A wrapped `Text` asked at 45 px whose longest word is 89.5 px cannot satisfy it, and neither can any widget that reads its box and reports something other than it. The answer box is not a question, so no contract about it can be demanded of the widget. - **It also let a caller narrow a frame by position.** A frame narrowed to a region (the worker's share frames) does not move when the part it sits in moves; only a frame narrowed to a *length*, put back into the part on every placement, does. ## The protocol now in the experiment **A widget draws once, in the box it is asked in. Its answer is placed inside that box by re-expressing the drawing. Nothing is drawn again in a box an answer chose.** `Holds` is a contract about the ask box alone, consulted only to decide whether a re-ask can be skipped. This is `draw_inner` at `3091fb8`: ```rust let reused = (!stale) .then(|| self.retained_answer(id, part, info)) .flatten() .and_then(|answer| { let extent = placed_extent(part, answer.0, declared, info.fill(), align); self.try_reuse(id, frame, part, extent, info, rsc) .map(|()| answer) }); let answer = reused.unwrap_or_else(|| { if old.is_none() { old = self.remove(id, false, rsc); } let answer = self.draw_at(id, part, info, old.take(), rsc); let extent = placed_extent(part, answer.0, declared, info.fill(), align); if extent != part { self.reposition(id, frame, extent, info, rsc); } answer }); ``` `try_reuse` checks the drawing against `part` and relocates it to `extent`; the old `place` (redraw in the answer box) is gone, and with it every offer field's purpose. `ActiveData` keeps `offer_part` as the ask box, `offer_place` as where it was asked and `place` as where it was put; the names are the old ones and should be renamed (`part`, `asked`, `placed`) when this lands. A container that puts an answer somewhere other than where it asked says so with a new call that never runs the body: ```rust /// Puts a child asked about in this draw somewhere else in this /// widget's box: its answer, placed in this part instead. The drawing /// is re-expressed there rather than made again -- what a row does once /// it knows every slot, having measured each child from its cursor. pub fn place_at(&mut self, id: &StrongWidget, place: [Place; 2]) ``` A frame is narrowed by a *length* of the parent's frame, never a region, and is put back into the part by the child's alignment on every placement: ```rust pub fn widget_at<'s, W: ?Sized>( &'s mut self, id: &'s StrongWidget, narrow: [Option; 2], place: [Place; 2], ) -> DrawResult<'s, 'a, W> ``` `Span` asks every child once from its cursor (`Within(From(cursor..far))`), then moves fixed children to their slots and asks share children once more in their decided slot with the frame narrowed to it: ```rust let slot = along(from, start); let place = axis.pair(Place::Fill(Part::From(slot)), across); let used = match len.leftover > Weight::ZERO && shares { true => { let mut narrow = [None; 2]; narrow[axis as usize] = Some(slot.len()); painter.widget_at(child, narrow, place).len(!axis) } false => { painter.place_at(child, place); size.axis(!axis) } }; ``` `Stack` asks non-sizing children in the box the sizing child decided, with the frame narrowed to it on every axis that is not a share, and `Scroll` asks its content once in the viewport and `place_at`s it to the scrolled offset. A local redraw asks the retained question again -- the same place of the box the parent was *asked* in -- and, if the answer stands, puts the fresh drawing back at the retained place of the box the parent's answer *chose*. Both halves are needed: seed 2 at depth 4 (a stack sized by its text) fails without the second. A symbolic length a child pinned now composes through `Part::Of` where the part is the whole box less pixels, and pins the parent's own length otherwise (`in_parent`). Dropping it let a zero `Pad` reuse a drawing across a narrowed frame of the same pixel length; the shrinker found six such seeds at depth 5. ## Decisions Decided with Bryan on 2026-09-17 and 2026-09-18, kept where still true. ### One draw method, in a box decided from above `Widget::draw` remains the only layout method. A container's body runs only in a box its parent offered or decided, never in a box derived from the container's own answer. **The experiment extends this to every widget:** a leaf is not drawn in its answer box either. Its drawing is re-expressed there, which for a text means the block it shaped at the asked width is positioned inside the box its reported size chose, and its lines do not change. `examples/text.rs` and `random` have not been rendered since; do that before landing and inspect any change. ### Frames are narrowed by every length decided from above A declared `px` or `rel`, a resolved share, and (new, planner's choice, not yet Bryan's) the box a stack's sizing child decided all narrow the frame. `declared_lens` still excludes `leftover`, which is right: a share has no length until the span divides its room, and it narrows the frame at the placing ask instead. ### `Pad` remains an outset -- and is not yet what was decided Bryan: padding goes outside what it pads; the pad forwards its frame less the padding and draws the child inside that area, so `rel(1.0)` inside `.pad(16)` inside a 450 px share is 418 px. **The experiment does not implement this.** `Pad` still forwards the frame whole and insets only the box: ```rust let inset = |lead: Px, trail: Px| { Place::Within(Part::Of(UiSpan::new( Len::from_parts(Rel::ZERO, lead), Len::from_parts(Rel::ONE, -trail), ))) }; ``` so `rel(1.0)` there is 450 and overflows by the padding -- the clipped `text.rs` render. The decided rule cannot be written in the current representation without a cost Bryan has not seen: - Narrowing the child's frame by the padding (`narrow = FULL - 32px`) makes that length the child's *box* too, since a narrowed frame is its own box. In a share or a declared box, frame and box coincide and the rule holds. As a fixed child of a span measured from its cursor, the padded child is then asked in `row - 32` rather than `room - 32`: a padded wrapped text after a 24 px icon wraps as if the icon were not there and overflows the row by 24 px. That row is the commonest thing in the app. - Keeping `Part::Of` (status quo) keeps the text wrapping in the room and makes `rel(1.0)` under a pad mean the whole frame. - Having both -- `rel` of the frame less padding *and* a box that is the room less padding -- needs the frame to be a length and the box a region in the parent's coordinates, with `Part::From` scaling its span by the frame length. That is a coordinate rewrite of `painter.rs`/`render_state.rs`, not a widget change. Ask Bryan which. Do not decide it in a worker session. ### A share never adds room beyond the deciding box `Scroll` resolves its content length from the fixed part of the answer and makes it at least the viewport (`4328eac`). Unchanged. ### Existing fixed-point and box-chain design stays Unchanged; see `docs/LAYOUT.md`. ## Implementation plan Work in `/home/bob/repos/iris-layout-experiment` on `wip/one-ask` from `3091fb8`. Make each step a warning-clean commit and run its named checks before the next. If a step exposes a different mechanism, stop and update this handoff rather than papering over it. ### 1. Read the long fuzzer results `docs/LAYOUT_LOG.md` records the 1000/6 oracle and the 2000/4 scan at `3091fb8` if they finished. If either found a seed, shrink it first (`SHRINK_SEED= SHRINK_DEPTH= SHRINK_CASE=`), pin it as a named test in `tests/cases/unsettled.rs`, then fix it under the rule above. Do not add a second draw back. ### 2. Render and replay Read the installed graphics skill and confirm the renderer. Render `view`, `minimal`, `random`, `tabs` and `text` at 1920x1200 against `34cafb6` and `e44dea3`, replay `tabs`, and compare a live resize of `random` with a cold render at the same size (commands under **Full verification** below). A text placed by re-expression rather than a second draw is the change most likely to show here; inspect every intentional difference and record it. ### 3. Rename and delete Rename `ActiveData::offer_part` to `part`, `offer_place` to `asked`, `place` to `placed`, and `DrawInfo` likewise; delete `ActiveData::measured` in favour of reading `answer`; delete `answers_at` if `resize` is its only caller and inline it. Every use was written against the old names on purpose to keep the probe's diff readable; do this as one mechanical commit. Suite, oracle. ### 4. Restore the expected retained cost Work counters at `3091fb8`, seed 1 and 13, depth 8, widget draws / distinct widgets, beside `e44dea3` (#18) and `49cec82` (the branch head before this): | seed 1 | e44dea3 | 49cec82 | 3091fb8 | | --- | --- | --- | --- | | cold | 369/261 | 516/288 | 331/288 | | many | 157/95 | 187/119 | 78/78 | | size | 16/12 | 3/3 | 3/3 | | scroll | 2 | 1 | 1 | | resize | 13/13 | 24/76 | 40/15 | | seed 13 | e44dea3 | 49cec82 | 3091fb8 | | --- | --- | --- | --- | | cold | 1330/707 | 2940/982 | 1179/982 | | many | 524/159 | 1091/423 | 335/333 | | resize | nothing | 2215/510 | nothing | `many` and `size` are better than #18 at seed 1 and `many` draws fewer times at seed 13, but it touches twice as many distinct widgets there, and `resize` at seed 1 draws 40 times where #18 drew 13. Two mechanisms, both understood: - **A share child is asked twice per span draw** -- in the measuring room with the frame forwarded, then in its slot with the frame narrowed. Each ask that reads pixels or pins a length draws. Give `Span` a measure-only ask for the first pass: reuse the retained *answer* when its holds contain the room, without validating or relocating the drawing, and let the placing ask settle the drawing. The answer contract must then carry no symbolic pin (a span's total does not depend on `far`; only its slots do), which is the separation the worker's experiment made with `answer_extent_len`. Measure `many` at seed 13 before and after. - **A positive-direction span with no shares pins `far`** it does not need, so a resize redraws it. Read `extent_len` only where a slot depends on it (shares, or `Sign::Neg`), and express the measuring room's far end without the length. Measure `resize` at seed 1 before and after. Report every phase at both seeds, work counters first, medians only when the work agrees. ### 5. Full verification and landing Run, in the experiment checkout: ```sh cargo fmt --all --check cargo clippy --workspace --all-targets -- -D warnings cargo clippy --workspace --all-targets --features layout-diagnostics -- -D warnings cargo test --workspace cargo test --release --test generated SHRINK_CASE=all SHRINK_SEEDS=400 SHRINK_DEPTH=5 \ cargo test --release --test shrink -- --ignored --nocapture IRIS_GENERATED_SEEDS=1000 IRIS_GENERATED_DEPTH=6 \ cargo test --release --test generated -- --ignored a_long_run_of_seeds_agrees SHRINK_CASE=all SHRINK_SEEDS=2000 SHRINK_DEPTH=4 \ cargo test --release --test shrink -- --ignored --nocapture ``` The last line is the 2000-seed depth-4 scan over all fifteen cases; the shrinker runs the same cases as the scan and reduces anything it finds, so no temporary test body is needed any more. `Rng::new` uses `seed | 1`, so adjacent even/odd seed pairs describe the same tree. Render `view`, `minimal`, `random`, `tabs` and `text` at 1920x1200 and inspect every intentional change. Also replay `tabs` and compare a live resize of `random` with a cold render at the same size. Read the installed graphics skill before rendering and confirm the renderer; an llvmpipe fallback can produce a plausible PNG. The headless rig reuses one compositor, so run one process at a time and give comparison worktrees separate target directories: ```sh ./scripts/run-headless.sh tabs --mode 1920x1200@60Hz --shot /tmp/tabs.png ./scripts/run-headless.sh tabs --mode 1920x1200@60Hz \ --resize 900x1200@60Hz --shot /tmp/resized.png ./scripts/run-headless.sh tabs --mode 1920x1200@60Hz \ --replay /tmp/tabs.touch --shot /tmp/replay.png ``` The reference replay is: ```text 0 down 1728 24 80 up 1728 24 400 down 1836 1116 480 up 1836 1116 800 down 1836 1116 880 up 1836 1116 ``` Before submitting, run the pre-submit review. Once the protocol lands, move any surviving fact from `docs/LAYOUT_LOG.md` into `docs/LAYOUT.md`, delete the log, update this handoff to the next actual task, update the app's Iris pin only when the Iris change is ready, and push every coherent commit. ## Follow-on work, not part of this repair - The `Pad` decision above, and the coordinate rewrite if Bryan wants both halves of the rule. - CPU round-to-nearest and shader nearest-pixel snapping are approved as one separately verified change. Neither has landed. Re-derive `Holds::through` for the new rounding and run both long fuzzers plus the render set. - Smaller layout items remain in `docs/LAYOUT_LOG.md`: an undrawn share's gap, nested share weights, inconsistent zero-divisor fallbacks, and the stale `f32` identity comment. - `LazySpan`, then `SizeRule::{Min, Max, Clamp}`. A cap may not contain `leftover`; whether `Max` narrows the child's drawing box is still a real product decision. - `Scroll` taking a direction rather than one axis. Other product work remains in `docs/PLAN.md` and the focused documents it links. Do not mix it into the Iris layout branch.