# 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 transparent frames 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` (the untracked `target-own/` there is its build output). It is the reviewed baseline this work must preserve or improve. `/home/bob/repos/iris-pr18` has since moved to a different WIP branch; do not use that checkout as #18. The continuation is `/home/bob/repos/iris-layout-experiment`, branch `wip/transparent-frames`, head **`4328eac`**, five commits over **`34cafb6`**. It implements transparent frames, pins the two open failures, and corrects `Scroll`'s content length, but it is not ready to replace #18: - the shrinker disagrees warm versus cold at seed 2 (`repaint`) and seed 108 (`reorder`) at depth 5; - `many` and `resize` still do more work than #18 on a deep tree; - a resolved `leftover` share does not yet narrow its child's frame; - step 3 exposed a widget-contract question that must be planned before the retained protocol can change; see **Step 3 stop** below. The app's Iris pin is unchanged. The step 3 experiment described below was reverted; the experiment checkout was clean at `4328eac` when last inspected. ## 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. The investigation tried those shapes and found that the protocol was asking an unanswerable question. ## Decisions Decided with Bryan on 2026-09-17 and 2026-09-18. ### One draw method, in a box decided from above `Widget::draw` remains the only layout method. A second measure method would duplicate layout and drift from drawing; a shared helper would merely move that obligation without removing it. 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. Measuring asks may be provisional while a parent is dividing room. Once the parent decides a slot, the child is evaluated in that slot; placing an answer is reuse or translation, not another `draw_at` in an answer-derived box. The current `offer` bit cannot express this. It is derived from `place == offer_place`, but one parent draw can evaluate a child in the parent's room, in a slot the parent decided, and in a box derived from the child's own answer. The fresh answer is right in some of those boxes and a retained answer in others. Remove the question rather than adding state that tries to answer it. The target retained model has one answer per widget. Its `Holds` contract says which parts it remains valid for. `redraw` re-asks it in the part of its last parent ask. The offer machinery can then go: answer gating, `offer_place`, `offer_part`, `at_offer`, and `measured()`. Keep `Part::Of`. It expresses a part of a widget's own box without making the container read that box's length, and was a sound addition to the experiment. Its dropped `extent_len` pin exposed seed 2, but composing every such pin through `Of` is not the repair: that experiment broke seed 220 and the region-node regression. ### Frames are narrowed by every length decided from above Containers that only divide room are transparent: absent a length decision, they forward the parent's frame. A declared `px` or `rel` length narrows the child's frame. **A resolved `leftover` share narrows it in exactly the same way.** The code's exclusion of `leftover` in `declared_lens` is a bug. A share is known only after the deciding span has measured fixed children and divided its room. The measuring ask therefore cannot settle a `leftover` child's frame. The placing ask supplies the resolved share as both its slot and narrowed frame, and fully evaluates the child there. This matters across the span too: a wrapping child's height may change once its width share is known, so the span reads that child's cross-axis answer from the decided-box evaluation, not from the provisional one. A box a widget merely reports does not narrow its descendants' frames. The frame changes because a parent decided a declared length or a share, not because the child's own answer happened to have that length. ### `Pad` remains an outset Padding goes outside what it pads. There is no mixed "outset pixels, inset fractions and shares" `Pad`. The clipped `examples/text.rs` render is evidence of the unresolved-share bug, not intended behavior and not a reason to replace `rel(1.0)` with `leftover`. If a 900 px row gives a padded child a 450 px share, that share narrows the pad's frame; after 16 px padding on each side, `rel(1.0)` inside the pad is 418 px and fits. Keep the example so it verifies that rule. ### A share never adds room beyond the deciding box `Scroll` currently calls `apply_leftover`, which turns any `leftover` in the content answer into a whole additional viewport. A row reporting `600 px + leftover` in a 900 px viewport therefore gets a 1500 px content box, its text re-wraps in room it was not measured in, and layout advances one fixed-point iteration each time it is evaluated. That behavior is wrong. A share takes the room left in the viewport. Resolve scroll content from only the fixed part of the answer and make it at least the viewport: ```rust let fixed = Len::from_parts(answer_len.rel, answer_len.px).to_px(container_len); self.content_len = fixed.max(container_len); ``` For `600 px + leftover` this is 900 px: the share receives 300 px and nothing scrolls. Pixel content still scrolls when its fixed length exceeds the viewport. A lone share fills the viewport. The content box no longer invents new room, so `Scroll` is not an own-answer-box exception to the decided-box rule. ### Existing fixed-point and box-chain design stays The fixed-point grid, `Holds::through`, the pixel box threaded down the draw, region nodes, and the warm/cold equality rule are sound. Their durable invariants are in `docs/LAYOUT.md`. Transparent frames fixed fraction resolution; the remaining defect is evaluating container bodies in multiple boxes and trying to infer which evaluation counted as measurement. ## Why the open seeds fail The complete traces, shrunk trees and counter-experiments are in `docs/LAYOUT_LOG.md`. The short version a worker needs is: - **Seed 2 (`repaint`)**: a stack measures a non-sizing subtree in room the stack will never have, then reuses that answer in the stack's one-line box. A dropped `Part::Of` pin makes the reuse look valid. Composing that pin fixes this seed but breaks seed 220 and an existing region-node test, so it is not the fix. - **Seed 108 (`reorder`)**: a nested span correctly evaluates a branch in its final 300 px box, but `draw_inner` discards the fresh 286 px answer for a retained 438.9 px answer from an earlier 450 px evaluation because the place expression changed. Always keeping the fresh answer fixes this seed but breaks four seeds and two tests under `Scroll`. - Those `Scroll` failures are the `apply_leftover` feedback loop above. Once that loop is removed, no legitimate container needs to lay children out in a box derived from its own answer. The worker faithfully implemented the earlier plan, tried four definitions of "measurement", restored the safe deferral when each failed elsewhere, and stopped. Do not resume that search. ## Implementation plan Work in `/home/bob/repos/iris-layout-experiment` from `49cec82`. 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. Pin the two failures as focused tests **Done in `b842e4f`.** The named tests reproduce the mismatch at `49cec82` and remain intentionally red until the protocol repair: - `unsettled::repainting_a_stack_uses_the_box_its_sizing_child_decided`; - `unsettled::reordering_nested_spans_keeps_the_answer_from_the_decided_box`. Turn the shrunk seed 2 and seed 108 trees from `docs/LAYOUT_LOG.md` into fast, named regression tests. Each must demonstrate the present warm/cold mismatch at `49cec82`, then pass because both paths select the same tree and boxes—not because the assertion was weakened. Also retain these nearby regression tests while changing the protocol: - `unsettled::a_widget_under_a_region_node_is_asked_in_the_box_that_node_was_offered` - `unsettled::a_span_given_the_box_its_answer_decided_matches_a_cold_layout` - the seed 86 `Scroll` fixed-point case - the tests for a length in pixels staying that many pixels and for an exact leftover split Check the ordinary suite and each new test individually. ### 2. Correct `Scroll`'s content length **Done in `4328eac`.** The four focused cases pass, as do the release fast oracle and the depth-5 counterexample seeds 184, 246, 292 and 372. The debug suite has 112 passing tests and only the two intentionally red tests above. Replace its `apply_leftover` content sizing with the fixed-part calculation above. Add focused cases for: - `600 px + leftover` in 900 px resolves to 900 px; - fixed content wider than the viewport still scrolls; - a lone `leftover` child fills without scrolling; - the wrapping-text-plus-share case is stable warm and cold. The seed 86 test stays until the full protocol has landed, even if its old special rule becomes moot. Check the suite, fast oracle, and the known Scroll counterexamples from the fresh-answer experiment (seeds 184, 246, 292 and 372). ### 3. Evaluate children in parent-decided boxes **STOP: this step needs planning before implementation continues.** A direct implementation was tried and fully reverted. It always kept the fresh answer, made placement reuse-or-translate (asserting if the retained drawing did not hold), asked `Span` children provisionally in the whole box before decided placement, gave `Stack` overlays the sizing child's reported box, and changed the random `Branch` tail to `Part::Of(40px..FULL)`. Both new focused tests passed, but 17 ordinary suite tests failed. Several failures state a load-bearing contract contrary to this plan rather than exposing another container bug. `ReadsBox` and `ReadsWidth` deliberately read the offered box, report one quarter of it, and their tests require two draws: one to answer and one in the answer-derived box. The tests' own comment says those draws come in pairs. `a_widget_asked_again_on_another_layer_is_drawn_there` likewise requires a second draw when placement changes the layer. Other failures include root resize/one-step validity tests, region-node placement, and `a_one_pixel_line_keeps_its_pixel_through_a_chain`. The unresolved decision is therefore public widget semantics, not another definition of measurement: either a widget must now guarantee that a drawing holds for the answer box it reports (and the contrary tests, documentation, and any real widgets must be redesigned), or placement sometimes has to run the body in an answer-derived box, contradicting the decided-box rule. Do not resume step 3 until that choice and its migration are written into the plan. Change placement so an answer-derived box never runs a container body. Placing becomes reuse-or-translate. Remove the offer/measurement gate and its retained bookkeeping only as each caller stops needing it; do not leave a parallel old path. Three current widgets must stop depending on measuring boxes they will never own: - `Span`: do not read `extent_len` unconditionally. Slots depend on `far` only when shares exist (the decided slot fills its part) or for negative direction; compute negative-direction slots from `total`. Pin the extent length only in those cases. - `Stack`: draw non-sizing children in `From(0..size)` on an axis where the sizing child's answer is `px`/`rel`, and `All` where it is `leftover`, instead of drawing them in `All` of the measuring room. - `Branch` in the random rig: express "the rest of my box" as `Of(40px..FULL)` rather than reading `extent_len(Y)`. Every drawing must hold for the answer box it supplies. The two focused tests from step 1 and the existing region-node and decided-box tests must pass here. ### 4. Make resolved shares narrow frames Give a `leftover` child its resolved slot as its narrowed frame at the placing ask. A span becomes a decided two-pass layout: 1. measure fixed children and collect share weights; 2. divide the deciding box's remaining room; 3. place/evaluate each child in its decided box, with a share child's frame narrowed to that share; 4. derive the span's cross-axis answer from those decided evaluations where a child's answer can depend on its share. Do not put `leftover` back into a declaration helper before it has a resolved length; unlike `px` and `rel`, its frame cannot be known during the first pass. Add tests that a `rel(1.0)` child directly inside a half share is half the row, and that the same child inside `.pad(16)` is the share less 32 px. The existing `examples/text.rs` case should render inside its padding without changing its width rule. ### 5. Remove obsolete machinery and settle the retained path Once all callers use the decided-box path, delete answer gating, `offer_place`, `offer_part`, `at_offer`, `measured()`, and the local-redraw deferral whose only purpose was distinguishing measurement from placement. Write `ActiveData::answer` in one place, and keep `DrawInfo` on `ActiveData` rather than copying fields and reconstructing it in `redraw`. Run the ordinary suite, fast oracle and shrinker before doing performance work. Both new focused tests must pass on cold, repaint and reorder paths. ### 6. Restore the expected retained cost Implement the known-length `Span` shortcut only after correctness is stable: a `px` child after a `px` child should draw once cold and never on repaint. Report cross-axis sizes from the decided evaluation, including wrapping share children. Compare all six diagnostic phases with `e44dea3`; investigate work-counter differences before interpreting time. Expected direction, not a license to weaken correctness: `size` and `scroll` keep their wins, `many` approaches #18's distinct-widget counts, and `resize` returns to about #18's 13 draws at seed 1. Record final counters in the temporary log and durable conclusions in `docs/LAYOUT.md`. ### 7. Full verification and landing Run, in the experiment checkout: ```sh cargo fmt --all --check cargo clippy --workspace --all-targets -- -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 ``` Then repeat the 2000-seed depth-4 scan over all fifteen cases. It is the only run that found seeds 1121 and 1839 before their fix; depth and breadth find different defects. `Rng::new` uses `seed | 1`, so adjacent even/odd seed pairs describe the same tree. The temporary scan target used this body and was deleted after the run: ```rust over_seeds((1..=2000).collect(), |seed| { let grown = plan(seed, 4, &Edits::default()); for &case in ALL.iter() { if let Some(how) = diverges(&grown, case, seed) { println!("HIT seed {seed} case {} {how}", case.name()); } } }); ``` Run all six `layout_diagnostics` phases at seeds 1 and 13, depth 8, against `e44dea3`. Compare work counters first; use medians only after the work is the same. Check that `UiSpan::within` still inlines with `nm`. 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 transparent frames 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. ## Verification already performed At `49cec82`: formatting, warning-clean clippy, the debug suite (108 suite tests and 20 core tests), the 11 generated cases, and all six diagnostic phases ran. The 400-seed depth-5 shrinker fails at seed 2 (`repaint`) and 108 (`reorder`); the long 1000/6 oracle and 2000/4 scan were intentionally not run after that. Reference renders against `34cafb6`: `view` and `minimal` were byte-identical; `tabs` differed by 2,332 pixels; `text` exposed the unresolved-share bug; `random` moved where nested spans do. At `e44dea3`, the #18 baseline: format, clippy and workspace tests passed; the release oracle at 100 seeds, debug oracle at 120 seeds, all fifteen shrinker cases at 400/5, the oracle at 1000/6, and the 2000/4 scan passed. The five reference renders and the `tabs` replay were byte-identical to their baseline, and a live-resized `random` matched a cold render. These statements describe those commits only; rerun them after changing layout. ## Follow-on work, not part of this repair - 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. - Test-only `Inset` and `Outset` can demonstrate their semantics after the protocol is stable. `Pad` itself remains an outset. - 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.