# Handoff Where the work in flight stands for a session picking it up cold. Keep current invariants, measurements, and failed hypotheses here; this is not a decisions log. Pruned on 2026-09-17: the chronicle of closed defects, superseded verification lists and cross-fixture tables went, the rules and the lessons stayed. ## Where things stand Canonical Iris `main` is **`ca2b4b2`** (#17, the headless rig). **#18 `split/18-position-chain`** is open in `/home/bob/repos/iris-pr18`, head **`e44dea3`**, pushed. It holds LAYOUT.md §2's position chain, `leftover`, the `Holds` retained-layout contract, region nodes, built-in alignment and size rules, fixed-point layout, a box in pixels threaded down the draw, and a report read as a fraction of the containing widget. No PR review was present when checked on 2026-09-15. The separate frame/extent experiment now addresses the dominant size/resize redraw cascade through dependency tracking and invalidation, not glyph emission. Its current head is `34cafb6` on `wip/region-and-placement` in `/home/bob/repos/iris-layout-experiment`. See **Frame and extent: retained prototype** for the mechanism. The app's framework pin is unchanged. **It is not ready to replace #18 yet**: against `e44dea3` it wins on `size` and `scroll` and loses on `many` and `resize`. The cause of the `many` loss was traced on 2026-09-17 and is not what the earlier sections say; read **Where the `many` gap actually comes from** before anything else in this document about performance, and take the earlier sections' explanations as history. **The current work is the plan in the next section**, decided with Bryan on 2026-09-18. It is what the next agent does, on top of `34cafb6`, and it supersedes the "Frame and extent" sections' description of `Span`, `Pad`, `Stack`, the placement pin and `measure_len` wherever they differ. ## Plan: transparent frames (2026-09-18) Decided with Bryan on 2026-09-18 after the trace below. The mechanism was checked against the code of `34cafb6` and against the two counterexamples on `wip/local-reask`; the pieces that were in doubt are called out. Do the steps in order and run each step's check before the next. **If a check fails and the fix that suggests itself is a tolerance, a pin, a deferral, a second layout method, or a special case in `Span`, stop and report instead**: those are exactly the patches that have been made around this design before, and each one made the next problem harder to see. ### Decided by Bryan - **Containers are transparent.** The frame a widget's fractions are of is forwarded from its parent through a span, a stack and a scroll unchanged, and through an inset narrowed by its margins. Any number of nested spans lay out against one frame. The reason: `px` already passes through, `rel` should behave the same way, and `leftover` is already the way to say "fill the containing widget", so `rel(1.0)` meaning that too would be two ways to say one thing. - A frame is narrowed only by what is decided from above: a declared length on the widget (`.width(rel(0.5))` on a span narrows its children's frame too), an inset's margins, the root. A box that is an *answer* (a row's height, a stack sized by a child) is never anything's frame. - `rel` overflows on purpose when it sums past one or has pixels beside it. - `Inset` and `Outset` take `px`, `rel` and `leftover` margins. Outset adds its margins to the child's report and moves the child in; Inset draws the child in a frame with the margins subtracted and reports the child's size plus what it subtracted. An inset resolves what it subtracts, so `px` and `rel` margins narrow the frame symbolically and a `leftover` margin is resolved in pixels from the extent after the child answers, like a span's shares. A general `Pad` would outset pixels and inset `rel` and `leftover`. **Do not build these yet**; make them a few lines each to write. - Along its own axis a span places a child whose length is known in pixels at its final slot while measuring, so it is drawn once, and moves a child that is in the wrong place, `px` or `rel`, by translation rather than drawing it again. Across itself it may still re-place by the answer. - Layers may later be tied to another widget (a popup near an anchor). A position is never defined by two widgets; positions compose up the tree. ### The protocol Two boxes per widget, both in the *parent's frame coordinates*: - **frame** -- what a declared or reported fraction is a fraction of. `UiRegion::FULL` for a transparent parent; narrowed by `ask_box` for a declared length and by an inset. Its *length* is the same on every ask of the widget, which is the property the whole retained model rests on. - **extent** -- where the drawing goes. Given by the parent as a `Place` per axis, **relative to the parent's extent start, in frame units**: ```rust /// Where a child goes along one axis, as a part of this widget's extent. /// Spans are frame lengths from the extent's start, so a span's slot is /// `from..start` and a moved extent re-places every child by re-adding /// its start, exactly. `None` is the whole extent. pub enum Place { /// The child's answer, aligned inside the part by the child's alignment. Within(Option), /// Exactly the part; the answer is not placed inside it again. Fill(Option), } ``` The widget's `draw` sees only its extent: `px_len`/`px_size` are the extent's pixels and narrow the extent range, `holds` widens it. Primitives and masks are written in extent coordinates (`FULL` is the extent); there is no `DrawRegion` and no frame-coordinate primitive. A container reads `extent_len() -> UiVec2`, the extent's *symbolic* length in frame units, which pins its drawing to that length and to nothing about the start. A span that divides room also reads `frame_px_len(axis)` (today's `region_px_len`) and widens through `frame_holds` (today's `region_holds`), which narrow the frame range. `placement()`, `region()`, `box_of`, `measure_len`, `widget_within`, `DrawRegion` and `ExtentPlacement` go. The parent side is one call, with a shorthand: ```rust pub fn widget_at<'s, W: ?Sized>( &'s mut self, id: &'s StrongWidget, frame: UiRegion, // in this widget's frame coordinates; FULL forwards it place: [Place; 2], // relative to this widget's extent start, in frame units ) -> DrawResult<'s, 'a, W>; /// The transparent default: the frame as given, the answer aligned in the extent. pub fn widget<'s, W: ?Sized>(&'s mut self, id: &'s StrongWidget) -> DrawResult<'s, 'a, W> { self.widget_at(id, UiRegion::FULL, [Place::Within(None); 2]) } ``` `Span`, measuring and placing, with the row being its own extent: ```rust fn draw(&mut self, painter: &mut Painter) -> Size { let axis = self.dir.axis; let far = painter.extent_len().axis(axis); // symbolic; pins the length let along = |from: Len, to: Len| match self.dir.sign { Sign::Pos => UiSpan::new(from, to), Sign::Neg => UiSpan::new(far - to, far - from), }; let across = Place::Within(None); // Measure. A child whose length is already known -- a rule, a hint -- // with no share before it is placed at its slot here and never again. let mut cursor = Len::ZERO; let mut shares_before = false; let mut lens = Vec::with_capacity(self.children.len()); for child in &self.children { let known = painter.size_hint(child, axis).filter(|_| !shares_before); let place = match known { Some(len) if len.leftover == Weight::ZERO => { Place::Fill(Some(along(cursor, cursor + Len::from_parts(len.rel, len.px)))) } _ => Place::Within(Some(along(cursor, far))), }; let len = painter.widget_at(child, UiRegion::FULL, axis.pair(place, across)).len(axis); shares_before |= len.leftover != Weight::ZERO; cursor += Len::from_parts(len.rel, len.px + self.gap); lens.push(len); } // total, room = far - fixed, the shares decision through frame_px_len and // frame_holds(through(room)): unchanged from today. // Place. A child already at its slot is an exact reuse; one whose slot // moved is recomposed, since its length did not change. for (child, len) in self.children.iter().zip(&lens) { // undraw a share with nothing to share, accumulate fixed/taken, then: painter.widget_at(child, UiRegion::FULL, axis.pair(Place::Fill(Some(along(from, start))), across)); } Size::from_axis(axis, total, ortho) } ``` Across itself a span reports the longest child in *frame pixels* among the `px` and `rel` parts of its children's reports, as that child's own `Len`. Comparing in pixels is safe here because the frame is decided from above and nothing feeds back; the read is a `frame_px_len` and narrows the frame range, and at the crossover both candidates are the same number of pixels, so the drawing is the same on either side of it. A child's `leftover` across a span contributes nothing to that: across, children overlap rather than divide anything, so a share can only mean "as tall as the span", and `Place::Within(None)` already fills the extent for a leftover answer. Only when no child has a `px` or `rel` part does the span report `leftover` itself and take its parent's room (Bryan, 2026-09-18: "returning rest if any have it is probably fine", refined to this because it costs nothing). So `row![text, rect]` is as tall as the text with the rect filling it, and `row![rect, rect]` fills its column's share. **Deliberately not done**: a fully transparent `leftover` across a span, where the span's height would be the larger of its tallest fixed child and the share its parent hands back. That needs the parent's division to come back down after the report, a second resolution pass; Bryan deferred it until the core is settled. Do not attempt it as part of this plan. The shapes the rest of the containers take (write them, they are short): ```rust // Stack: the sizing child takes the whole extent, the rest are aligned in it. painter.widget_at(child, UiRegion::FULL, [Place::Fill(None); 2]).size() // sizing painter.widget_at(child, UiRegion::FULL, [Place::Within(None); 2]); // others // Scroll: viewport is the extent; content is a pixel box offset by the scroll. painter.widget_at(&self.inner, UiRegion::FULL, self.axis.pair(Place::Fill(Some(UiSpan::px(anchor - amt, anchor - amt + content_len))), Place::Fill(None))); // Inset with px/rel margins m: a narrowed frame and a narrowed extent. let frame = m.narrow(UiRegion::FULL); // Len subtraction, exact let len = painter.extent_len(); painter.widget_at(child, frame, [Place::Within(Some(UiSpan::new(m.lead.x, len.x - m.trail.x))), /* y */]).size() + m.total() // report child plus margins // Outset: the same placement with the frame forwarded (UiRegion::FULL). // Leftover margins: measure the child with Within(None), divide the room // left in the extent by weight as a span does, then place with Fill. ``` A report is still `LayoutLen { px, rel, leftover }` per axis. A fraction in it is of the reporting widget's frame, so through a transparent parent it passes unchanged and through a narrowing parent (declared, inset) it is composed by `within_len(frame.len())` as `in_parent_frame` does today. A stack sized by a child that reports `rel(0.5)` therefore reports `rel(0.5)`, is placed at half the frame, and hands its child the whole extent while the child's frame is still the full one: the fraction is applied once. ### Retained state and reuse Per widget (`ActiveData`), replacing `region`/`placement`/`given_region`/ `offer_len`/`offer_placement`: - `frame: UiRegion` in the parent's frame coordinates, and `place: [Place; 2]` as last given; `offer_place: [Place; 2]` from the first ask of the parent's draw at the offer. There is no `offer_frame`: the frame's length is the same on every ask, and `redraw` asserts it in debug. - `frame_abs`, `extent_abs`: the two composed into `parent_move` coordinates, for writing primitives and for `window_region`; rewritten by recomposition. - `answer: Option<(Size, LayoutHolds)>` from the offer ask; `holds: LayoutHolds` for the drawing, where ```rust pub struct LayoutHolds { pub frame: [Holds; 2], // frame pixel lengths pub extent: [Holds; 2], // extent pixel lengths pub extent_len: [Option; 2], // symbolic extent length, where read } ``` The `placement: Option` pin is gone. Nothing may depend on where an extent starts. - primitives and the mask retained in extent-local coordinates, as now. **Reuse** of a drawing at an ask: same layer, parent move and region-node choice; frame pixels inside `frame`; the extent resolved from `place` (`Fill` is the part; `Within` is the answer aligned in the part, or the part where the answer fills) has pixels inside `extent` and, where pinned, the same symbolic length. A frame that moved recomposes the subtree from retained local coordinates (today's `recompose_subtree`). An extent that moved **re-places every child** through its retained `place` (`extent_abs.start + place`, then the child's own reuse test), stopping at region nodes; this is today's `reposition` over `extent_children`, generalised to all children because every child is now placed relative to the extent. A child whose reuse fails there is drawn again at its retained place. Along a span this is what moves `px` and `rel` children whose slots shifted: the span's redraw re-issues `Fill(from..start)` with the same lengths and different starts, and the child recomposes. **A `Within` ask does not place the answer immediately.** The child draws in the whole part, and the aligned answer box is applied by the next ask of that child in the same parent draw, or, for a child not asked again, by a pass at the end of the parent's draw over `children`. That is today's `measure_len` rule made the rule for every open axis; it is what keeps a span child at one draw plus one recomposition rather than two recompositions. **Dependencies composed into the parent** (today's `in_parent`): a child's `frame` range goes through the child's frame length into the parent's frame range. A child's `extent` range goes into the parent's *frame* range through the part's length where the part is a span (a frame length), and into the parent's *extent* range where the part is `None` (the whole extent). Answer dependencies come only from children whose answer was read, drawing dependencies from every child drawn, as today. Pins do not compose: a child pinned on its symbolic length is checked when it is re-placed. **Local redraw** (`redraw`): no deferral on lengths. Draw at `offer_place` with the drawing left there where the given place differs (measuring), compare answer and holds with what was retained, keep a still-covering old guarantee as today, mark the parent only on a change, then place at the given `place` (a reuse where the holds admit it). Deferral remains only for a changed declared length or alignment and for an undrawn widget. The `given_px != offered_px` branch and `offer_len` are deleted, not disabled. ### Steps, each with its check Work on a branch from `34cafb6` in `/home/bob/repos/iris-layout-experiment` (its `target` is its own; the baseline's is `target-own`). The checks are `cargo test --workspace` in debug, `cargo test --release --test generated` (fast oracle), and after steps 4, 6 and 7 the long ones: ```sh 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 ``` 1. **`Place` and `widget_at(child, frame, [Place; 2])`**, with `widget` as the shorthand, replacing `widget_at`/`widget_within`/`widget`/ `measure_len`. Internally keep `draw_inner` but make `DrawInfo` carry `frame` and `place`; delete `DrawRegion`, `ExtentPlacement`, `reads_placement`, `region()`, `placement()`, `box_of`. Add `extent_len()`, rename `region_px_len`/`region_holds` to `frame_*`. Check: it compiles with the call sites moved in step 2; no test yet. 2. **Call sites**: `Span` as above (without the known-length shortcut yet), `Stack`, `Scroll`, `Pad` (as an inset by pixels, unchanged behaviour), `Masked`, `Branch` in `random.rs`, `Text` (`glyphs` in extent coordinates), the examples. Check: suite and fast oracle. Expected to pass with `Span` reading `extent_len` on both axes and pinning it. 3. **Re-place every child on an extent move**, generalising `reposition`; delete `extent_children`. Check: suite, fast oracle, `retained::a_span_ruled_across_itself_moves_its_child_without_redrawing_it` and a new test: a row whose first child grows by a pixel rule moves the two after it without drawing them (count draws with the `Counted` widget in `tests/cases/retained.rs`), once with a `px` second child and once with a `rel` one. 4. **The symbolic-length pin replaces the placement pin**; `LayoutHolds` as above. Check: suite, fast oracle, shrinker at 400/5, and the rig's `many` at seed 13, depth 8 (`IRIS_SEED=13 IRIS_DEPTH=8 IRIS_PHASE=many`): "reuse outside: the placement it was pinned to" is gone as a counter and distinct widgets a frame should already be well under `34cafb6`'s 508. 5. **Lazy `Within`** with the end-of-draw pass. Check: suite; `retained::a_span_does_not_place_its_measurement_before_assigning_the_childs_slot` still counts three draws. 6. **`redraw` without the deferral**, as above; delete `offer_len` and the `given_px != offered_px` branch. Check: suite, fast oracle, shrinker, oracle at 1000/6 -- this is the step `wip/local-reask` failed at seeds 532 and 398, and both must pass now because the frame no longer changes under the widget. If either still fails, shrink it and report; do not restore the deferral. Then the rig: `many` at seeds 1 and 13, depth 8, against `e44dea3` in `/home/bob/repos/iris-layout-baseline` (`CARGO_TARGET_DIR=$PWD/target-own`). Expect distinct widgets near `e44dea3`'s 95 and 159 and no deferral at all. 7. **Span's known-length shortcut and the cross-axis report** as written. New tests: a `px` child after a `px` child is drawn once on a cold layout and never on repaint; nested rows two deep with `rel(0.5)` give half the *root* (or half a declared `.width(rel(0.5))` ancestor) wherever the child sits; a row with a `rel(0.5)`-tall child reports `rel(0.5)` across. Check: everything, including the long runs and the five reference renders (`view`, `minimal`, `random`, `tabs`, `text`) against `34cafb6` -- expect `random` and `tabs` to move where nested spans or span heights change meaning, and look at them rather than diffing. 8. **Prove `Inset`/`Outset` are easy**: write them as test widgets in `tests/cases/layout.rs` with `px`, `rel` and `leftover` margins, a dozen lines each, and assert the child's box; do not add them to the crate. 9. Measure all six phases against `e44dea3` with the uninstrumented rig, record the table here, and update the "Retained-layout invariants" list: delete the bullets about `reports_of`, `decided`, `dirty_size_under`, the placement pin and the offer-length deferral, and add the frame rule. Expected against `e44dea3` at seed 1 and 13, depth 8: `size` and `scroll` keep their wins; `many` within a few tens of percent, from the container body running at the offer placement and at the placed one; `resize` at or below its 13 draws. A `many` result above 2x is a sign something above was not done as written, not a reason for a new mechanism. **Widen one fuzzer axis at a time, and record which.** Seeds **1121** and **1839** at **depth 4** failed on `ea6dbae` and on every commit before it, and nothing in the routine verification reached them: the fast oracle takes ten seeds, the shrinker 400 at depth 5 and the long oracle 1000 at depth 6, so a defect past seed 400 at depth 4 had nowhere to show. Both are fixed by `4bd8607`. The scan that found them, worth running again after any change to layout: ```rust // tests/scan.rs, deleted once it had done its job 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()); } } }); ``` 261 s for 2000 seeds at depth 4 over all fifteen cases, and clean at `4bd8607`. `Rng::new` is `seed | 1`, so an even seed and the odd one above it are one tree: 1120 and 1121 are the same counterexample, as are 1838 and 1839. **Two ideas outrank everything else in this document** (Bryan, 2026-09-17). First, a changed tree lays out exactly as if it had been drawn that way from the start; that is what the retained machinery is for and what the oracle and shrinker check. Second, widgets are predictable: `px` is that many pixels, `rel(0.5)` is half of the containing widget's area however many siblings there are and wherever it sits among them, and `leftover` is a share of the room left once every sibling's `px` and `rel` are resolved. The invariants listed further down were accumulated by agents chasing single failures; any of them may be simplified or deleted if those two ideas still hold. ## Review of 2026-09-17 A fresh read of `core/src/fixed.rs`, `orientation/`, `ui/holds.rs`, `ui/painter.rs`, `ui/render_state.rs` and the position widgets, outside of doing work on them, with each finding checked by a scratch test. **Verdict.** The concepts are sound and stay. Fixed point on a `1/1024` grid is the right base for a layout that decides "same box or not" by equality. Threading the pixel box down the draw, with `Holds::through` the exact preimage of that one multiply, is the strongest idea in the code: layout has one route to every length and the reuse test is its exact inverse. Offer, given and placed is the ordinary measure-then-arrange model. What needed work was the bookkeeping around the second ask, one boundary in `Span` computed by an expression other than the drawing it guards, and a `rel` that meant two things. The two-step residual is structural and no grid width fixes it; where it becomes visible is the shader's snap. ### Decided by Bryan - **`rel` is a fraction of the containing widget's whole area.** In a span, `rel(0.5)` is half the span whatever else is in it and wherever it sits. It is never a fraction of what was left after earlier children. This supersedes the 2026-09-16 reading that a report is a fraction of the box the widget was given, wherever that box is a remainder rather than the child's whole area. - **`draw` stays the only layout method on `Widget`** (Bryan, 2026-09-17, reversing the same day's acceptance of a measure/draw split). Ease of writing a widget is half the reason. The real one is that a second method holding the same layout drifts from the first, which a span makes extremely easy, and the shared logic then gets pulled into helpers both call that still have to be applied carefully in each. Where the framework needs a widget's layout twice it runs the same body again with the painter in a different state, or hands it more through the painter. - **A widget's frame does not change between the ask that measures and the ask that places.** Fractions are of the frame; the extent reaches the widget through the painter. See "Frame and extent" below. ### A report is a fraction of the containing widget (landed, `ffd79f3`) **Superseded in part.** Bryan refined the rule twice on 2026-09-17: a fraction means whatever the *parent* says it means, and it need not even mean the same thing for two siblings -- "Main point is truly that rel is decided by parent". The span conclusion below is unchanged and the reason for it stands (a span's offer differs per child and the row does not), but `reports_of` is the wrong shape for saying it; see the parked padding branch. `rel(0.5)` is half the span whatever else is in it and wherever the child sits. A report used to come back composed through the box it was offered, and a span offers each child the room from its cursor, so a nested span taking half of what it was given took a quarter of a row whose first half was spoken for -- where the same half written as a rule on the child took half the row. The offer is still the remainder, because a text has to wrap at the width actually there. What separated from it is the base a report's fractions are of, which the ask now carries as `reports_of`: ```rust pub fn widget_at<'s, W: ?Sized>( &'s mut self, id: &'s StrongWidget, region: UiRegion, reports_of: UiVec2, decided: [bool; 2], ) -> DrawResult<'s, 'a, W> ``` It is `region.size()` wherever the box offered is the child's whole area -- `Pad`'s inset, a `Stack` child, `Scroll`'s content -- and `Span` passes `UiVec2::FULL_SIZE` along its row. `widget_decided` is gone; `widget_at` says both things about an ask rather than one of them. **A span can now overflow itself without bound**, which is the consequence Bryan's rule asks for: two children reporting half each take the whole row and a third starts past the end. Under the old reading `total.rel` could not exceed one, so `Span`'s `fixed <= 0` branches were only reachable through declared fractions; they are ordinary now, and with them boxes of negative length passed down to children. ### An answer is not an answer while anything under it is dirty (`0e0d4af`, superseded by `a0693ac`) **Superseded: `dirty_size_under` is deleted.** Once a resize goes through the settling walk the ordering makes the whole category unreachable rather than checked, which is Bryan's steer and the better answer. The defect and its reasoning are kept below because they say what the ordering is buying. `draw_inner` took an answer from `try_reuse`, which checks only whether the widget itself is marked, where `retained_answer` beside it also refused one while anything the widget read a size from was dirty. A widget whose drawing happened to be reusable therefore handed back the answer it gave before that descendant changed, and nothing puts it right: the comparison that tells a reader its child's answer moved is in `redraw`, and a widget settled inside its parent's own draw never goes through it. The placing ask redraws the subtree, the descendant's mark is cleared there, and the parent keeps a number the tree no longer agrees with. So `dirty_size_under` was not the optimization its comment claimed: it was what made an answer an answer, until the walk made the state it guarded against impossible to be in. Found at seed 564, depth 6, `shuffle-every-other`, reachable only once a span could overflow itself. No hand-built tree ever reproduced it, and the seeds at depth 4 above fail for some other reason. ### A text is handed back a box its own line fits in (landed, `4bd8607`) A wrapping text reported the width it used through `Px::from_f32`, which takes the nearest step and is under the line the shaper measured half the time. A parent that sizes itself from that report -- a stack taking a span's width, the span taking its widest child's -- then hands the text back a box its own longest line does not fit in, and a greedy break there is one line longer. Warm kept the break it had; cold made the narrower one. Two tolerances were holding that together and both are gone: - `TextBuffer::shape` answered any width within `BREAK_EPSILON_PX = 0.05` of the longest line from the break in hand. Fifty steps of the grid, and a structural decision taken on a hair's breadth -- the thing the `Span` boundary invariant below already forbids. It is `want >= layout.width()` now, exactly. - The `Holds` range the text declares started at the nearest step to its longest line, so it admitted boxes that line does not fit in. It starts at `Px::ceil_from_f32` of it now. Neither was the fix. **The fix is the report**: `Size::from_px( PxVec2::ceil_from_f32(tex.size))`, the step at or above what was measured, so the box that comes back fits. With it in place either tolerance could have stayed and the case passes; both are wrong on their own terms, so both went. `Fixed::ceil_from_f32` is new and is the only rounding on the grid that is not to the nearest step. The general shape, and the third time this branch has hit it: **a value that comes back as a box has to be rounded away from the measurement, not to the nearest step.** Rounding to nearest is right for a value being carried; it is wrong for a bound. ### A frame settles strictly bottom-up (landed, `a92c6ac`) The queue was already deepest-first, but a widget that could not settle where it was called `redraw` on its parent from inside itself, which drew a shallow widget while dirty widgets deeper in other subtrees were still pending. A parent drawing over a subtree that has not settled reads answers about to move, and the one that settles does so inside the parent's draw -- where its mark comes off and nothing compares what it now answers. A widget that cannot settle defers instead: it marks its parent, stays marked, and waits in `deferred` until the walk reaches the parent's depth, which cannot happen before everything deeper has settled. ```rust loop { let next = rsc.widgets().needs_redraw.iter().copied() .filter(|id| !self.deferred.contains(id)) .max_by_key(|&id| self.depth(id)); let Some(id) = next else { break }; if !self.redraw(id, rsc) { self.deferred.insert(id); } } ``` Bryan's, 2026-09-17, and the right answer where `0e0d4af` was a check: "then that entire category of issue can't even occur". **The walk is sound on its own, and the second entry point is closed** (`a0693ac`). By induction on depth: when a widget at depth d draws fresh, every dirty widget deeper has been popped, so each is settled or deferred, and a deferred one has marked its parent. A clean child asked by that draw therefore has a clean subtree, because anything dirty under it would have a dirty parent, and so on up to the child itself. The entry point that was left was `update` drawing the root for a resize before the walk ran, top-down over a tree with dirty widgets still in it. It is closed by marking the root instead, so layout is one walk a frame and `dirty_size_under` is gone at both call sites. **The root is marked only where the new output falls outside what its answer holds for**: that range is the intersection of everything under it, so admitting the new output says the whole tree stands, and nothing above the root moved. Marking it unconditionally cost the root its own `Holds` -- a leaf root that scales with its box was drawn again on every resize, which two tests caught. The rig says the cost is scheduling only: on `resize`, one queue pop, one local redraw and one depth read appear and one failed reuse attempt goes, and every other counter on `cold`, `repaint`, `many`, `size`, `scroll` and `resize` is identical. ### A subtree that changes hands is recorded on both sides (landed, `e44dea3`) A subtree can be reused whole under a different parent -- same box, same layer, same region node, clean -- and nothing in the drawing says it moved. Two things read who its parent is, and both were wrong after one of these. - The **old parent still listed it**, and a parent's next draw undraws whatever is missing from that list. Two spans under one root, with the root swapping which of them it holds, drew the subtree under the new span and then erased it when the old one drew. - Its **depth** was the one it had under the old parent, which is what the settling walk orders by, so a change made under it afterwards settled at the wrong point in the frame. Both are written where `draw_inner` already records what the ask decided: `active.parent` is replaced and the old parent's `children` repaired, and `try_reuse` re-walks the subtree's depths -- only where the top of it moved, which is what makes that free in the ordinary case. Pinned by `retained::a_subtree_that_changed_parents_is_not_undrawn_by_the_one_it_left` and `..._settles_at_the_depth_it_moved_to`; each fails without one half. The fuzzer never re-parents (`reshuffle` only trades children between a span and its own spares), which is why nothing generated reached either. ### A span's leftover boundary is its own inverse (landed, `53b00c6`) The decision used a rounded division where the room the children get is a floored multiply, so the boundary and the drawing it guarded were two expressions for one length. `room` is that length as a `Len`, `room.to_px` is the multiply, and `Holds::through` is its exact preimage: ```rust let room = Len::rel_max() - Len::from_parts(total.rel, total.px); let mut shares = false; if total.leftover > Weight::ZERO { shares = room.to_px(painter.px_len(axis)) > Px::ZERO; let holds = match shares { true => Holds::from(Px::STEP..=Px::MAX), false => Holds::from(Px::MIN..=Px::ZERO), }; painter.holds(axis, holds.through(room)); } ``` The three branches were the sign of `1 - rel`, which `through` reads already. Forty lines became twelve and one `div` left layout. The general rule stands and is now demonstrated: **derive a boundary through the inverse of the expression that draws, never by a second expression for the same length.** ### Two branches parked, both real, neither ready **Superseded on 2026-09-17: the two branches are one defect, and it is in the protocol rather than in either widget.** See "Frame and extent" below. Do not chase seed 1091's three steps or the Inset's 47.5 px; both are the placing ask resolving a fraction in the answer box. What each branch got right is kept: the `Inset`/`Outset` pair and its tests, and the stack test asserting that half stays half. Their painter changes go. **`wip/stack-fraction-twice`.** A stack sized by a child that reports a *fraction* applies that fraction twice: its parent places the stack at the reported length, and `box_of(size)` then takes the same fraction of that box. Half a row becomes a quarter. Pixels are idempotent under a second application, so only a share ever shrank -- and warm and cold shrink alike, so **no oracle can see it**. Bryan: "I believe this exact thing has come up multiple times for some reason." The fix is that every child gets the whole box, plus `widget_at` not resolving a rule into a box already chosen from it. `Painter::box_of` is deleted rather than guarded: a first attempt made it answer the whole box on a `decided` axis, which fails because `place` can reuse the stack's *measuring* drawing by remapping it, so `Stack::draw` never re-runs. **A drawing has to be a function of its box alone** -- if `decided` is in it, a reused drawing is wrong. What stops it landing: seed 1091 at depth 4, `shuffle-swap-for-three`, disagrees by three steps where the oracle tolerates two (warm 1053.9971 against cold 1054). `box_of` was also making placement *exact*, by handing a child a box of exactly the length it asked for, and the whole box puts a rounding back at each nesting level. Find that composition; do not widen `AGREE_STEPS`. **`wip/padding-outset-and-inset`.** Padding goes outside what it pads and never insets the child (Bryan, 2026-09-17): otherwise a child's `rel` and `leftover` would mean the inner box while its `px` meant the outer one. `Padding::region` moves the child's box in rather than shrinking it, a new `Inset` widget with `.inset()` is the old behaviour, and `Pad` is to be renamed **`Outset`** with `.outset()` to match. `in_parent_frame`'s composition and the `reports_of` argument go with it -- a report comes up raw and the parent says what it is a fraction of, which `Inset` does for itself. What stops it landing: a child declaring `rel(0.5)` under an `Inset` comes out 47.5 px wide of the 190 inside rather than 95, and the second halving is unaccounted for. The `Pad` half is green on its own; three tests moved to `.inset()` because they were using padding as scaffolding rather than testing it. ### `Span`'s leftover boundary is a third expression for the room The decision uses a rounded division, `total.px.div(fixed)`, while the room the children get is a floored multiply, so the two disagree at the boundary. Measured with 300 px, `rel(2/3)` and a `leftover` child: | row width | leftover child | its threaded length | | --- | --- | --- | | 900.000 | undrawn | | | 900.001 | drawn | 0 steps | | 900.002 | drawn | 0 steps | Harmless at two steps, but the three-branch block collapses into the inverse that already exists. `room` is computed a few lines below the block as `Len::rel_max() - Len::from_parts(total.rel, total.px)`, and its `to_px` is exactly the threaded length the leftover children share: ```rust let room = Len::rel_max() - Len::from_parts(total.rel, total.px); let mut shares = false; if total.leftover > Weight::ZERO { shares = room.to_px(painter.px_len(axis)) > Px::ZERO; let holds = match shares { true => Holds::from(Px::STEP..=Px::MAX), false => Holds::from(Px::MIN..=Px::ZERO), }; painter.holds(axis, holds.through(room)); } ``` `through` already handles a negative fraction and a zero one, so the `fixed < 0` and `fixed == 0` branches go with it. The general lesson: `mul` floors while `div`, `div_int` and `ratio` round to nearest, so a boundary derived with a division guards a drawing made with a multiply. Derive boundaries through `through`, or make the grid floor everywhere. ### Where the residual comes from, and the snap The `e44dea3` baseline's two-step allowance came from inverse remapping and alignment composed by different routes. The frame/extent continuation below retains local widget frames as well as local primitive coordinates, recomposes in the same order as a cold draw, and removes inverse remapping. Its oracle requires exact pixel-region equality. That experiment is not yet the pinned framework; the baseline's snap decision below still applies there. Where it does matter is `snap_floor` in `prelude.wgsl`, which adds half a layout step before flooring: that absorbs float error and not a layout step, and truncation makes "one step under an integer" the common residue. A third of 900 px is 299.999 on the grid and lands at 299 on screen, which is the pixel `08c9d5a` moved `tabs`'s arcs by. Rounding to the nearest pixel absorbs both the truncation and the two-step residual everywhere except within two steps of a half pixel, where layout never lands on purpose, and keeps integer widths for equal fractional parts: ```wgsl fn snap_floor(v: vec2) -> vec2 { return floor(v + 0.5); } ``` Approved by Bryan on 2026-09-17, together with rounding on the CPU; see item 5 under "Next" for why both, and what each does not fix. The check is the reference render set plus the oracle; expect `tabs` to move its arcs back. ### Smaller items - The comment on the `local == UiRegion::FULL` shortcut in `widget_at` says composing through `FULL` "is not quite the identity in f32". On the grid it is exact; the shortcut is performance only now. - An undrawn `leftover` child still contributes its gap, so a vanished child leaves a double gap. - Nested spans pass `leftover` weight up, so three leftover children in one inner span beside one in another get three quarters to one quarter. No other layout system does that, and the doc's old example of two and two did not distinguish it from per-span division. Confirm it is wanted. - `Fixed::div` by zero answers `MIN`/`MAX` while `ratio` answers `ZERO`; both are caller bugs under `debug_assert`, but the fallbacks differ. ### Frame and extent: retained prototype The original proposal was implemented by Claude as `5fcace1` on `wip/region-and-placement` in `/home/bob/repos/iris-pr18`. It kept the fraction reference stable but failed five layout cases and three draw-count cases. The first correction is `efb416b`, exact recomposition is `2ed5503`, and the current performance continuation is **`0e107f0`**, pushed to origin's `wip/region-and-placement`, in the isolated checkout `/home/bob/repos/iris-layout-experiment`; **it is not on PR #18**. The original checkout is unchanged. Keep the experiment's build directory separate: Cargo can accept a different worktree's artifacts as fresh when a target directory is shared. The comparison checkout's source timestamps can precede its build. **The distinction stays.** `Painter::region` is the frame, the parent's chosen reference for fractions. `placement` is the extent in that frame. A sizing report must not replace the reference against which that report was obtained. `px_len` reads the extent's pixel length; `region_px_len` reads the frame's. Text wraps at the available extent, not the whole fraction reference. **The offer includes availability.** A span can keep the same reference frame while offering a text less room after an earlier sibling. `measure_len` therefore accepts the placement as well as the frame. The first measurement's `offer_placement` survives later placing evaluations, and local redraw asks that original question before restoring the assigned slot. Frame-length equality alone no longer identifies the measurement question. **An answer and a drawing each retain their dependencies.** `LayoutHolds` keeps frame ranges, extent ranges, and an optional raw placement snapshot. `px_len` narrows one extent axis without making position or the other axis a dependency; `holds` widens that extent range without erasing a frame read. `Holds::through` still inverts one fixed mapping exactly. A measured answer and the final drawing can only substitute for one another when those independent inputs satisfy the retained contract. The old fallback checked only frame ranges, so text placed at a fixed pixel width answered a narrower window's measurement with its previous line breaks. **Geometry retains its reference.** `DrawRegion::{Frame, Extent}` records local primitive and mask coordinates before composition. Ordinary `primitive()` follows the extent; explicit `UiRegion` arguments remain frame-relative. Text glyphs use extent-relative origins. `Painter::widget()` records a child that inherits placement, so a pass-through wrapper and its valid descendants can be repositioned without rerunning their draw bodies. `placement()` is still a conservative raw read for arbitrary widget computations. **No measurement is different from a measured zero.** The first wider scan found seeds 560 (`shuffle-swap-for-three`) and 1690 (`shuffle-add-three`) at depth 4. A previously undrawn pure share had only supplied a hint; its placeholder zero answer became reusable once it was drawn during placement. `ActiveData::answer` is now optional. Both seeds pass after that correction, and `adding_text_to_a_reverse_row_keeps_its_shared_height` reproduces the zero-height failure with a small tree. **Exact recomposition and reuse.** The continuation replaces `given_len` with `given_region`: each widget retains its original frame in its parent's coordinates. Moving a retained subtree replays those same local compositions, and stops at region nodes. `RegionRemap`, `AxisRemap`, and inverse division are gone. Fixed-pixel frames can now change size without forcing otherwise valid descendants to draw again. This adds 16 bytes per active widget; local primitive coordinates were already retained by `efb416b`. The shared oracle now compares pixel regions with ordinary equality; there is no `AGREE_STEPS` allowance. Text publishes the range of its retained line breaks rather than narrowing that range to every later requested width. A layout with only explicit line breaks or no breaks holds at arbitrarily wider widths. New tests compare actual primitive and mask geometry and assert reuse for fixed-frame resizing and widening unwrapped text (including explicit newlines and empty content). **Measure the offer without placing an intermediate answer.** `Painter::measure_len` replaces the hint/cache query and its duplicated drawing fallback. It still runs `Widget::draw` when needed, but leaves that drawing in the offer until the parent assigns the child's slot. Placing the child's own answer first was wasted work: the parent immediately replaced that placement. The regression counts three rather than four evaluations for a numeric-size leaf in a span, checks its actual primitive bounds, and repeats after resize. There is no second layout body on `Widget`. `Holds::ANY.through(...)` now returns `ANY` directly: an unrestricted range needs no inverse division, including for a negative fraction. **A chosen slot does not cancel a child's alignment.** Bryan caught this in the `tabs` image: a fixed 100 px square under a flexible wrapper sat at the slot's near edge. `ask_box` now aligns a declared frame inside the chosen slot, just as it does inside an unassigned offer. A regression asserts the actual coordinates before and after resize. Warm/cold equality could not find this because both were wrong. The corrected 900 px image still has 88 px between blue and red and 78 px between red and orange: the enclosing `.pad(10)` adds 10 px before the red slot. Red is centered in that slot, 5 px right of the visible black gap's center. Do not describe those two centers as the same thing or compensate with an arbitrary offset. The parked padding API change is still separate. **The major update gap was over-invalidation, not a required correctness cost.** Two different dependency graphs had been accumulated into one range. A child's size answer constrains its parent only when the parent reads that answer; the child's drawing constrains the parent's retained drawing whether its size was read or not. A stack sized by one child must not remeasure because an unmeasured overlay wrapped at another width during provisional layout. `Painter` now collects these contracts separately. `DrawResult::size()` and `measure_len` contribute answer dependencies; every child draw contributes drawing dependencies. Direct numeric and raw-placement reads conservatively constrain both. The original offer retains the answer contract, and final placement retains the drawing contract. Previous attempts separated storage without separating which child dependencies entered each contract, so they continued to invalidate measurements for unrelated drawing work. **A valid answer does not certify a valid drawing.** Root resize checks both. Local updates propagate an invalidated drawing contract as well as an invalidated answer; otherwise an ancestor can retain a broad resize range after a descendant begins reading its pixel width. Focused regressions cover both paths and fail when those checks are removed. No geometry check or equality requirement was relaxed. **A wider validity range does not invalidate an existing guarantee.** When a local redraw returns the same size under a contract covering the old one, the old answer contract remains sufficient. Keep it, and likewise keep a still-valid old drawing contract. Adopting the wider range without telling the parent would lose which guarantee the parent relies on; keeping it prevents both widening and narrowing back from causing layout. Scroll alternates between an exact width and a wider interval as it enters/leaves end anchoring. Comparing whole contracts by equality caused four draws and doubled scroll cycles after the initial dependency split (`f860f71`). Retaining the valid guarantee reduces scroll to one draw, below the previous baseline's two. It introduces no retained state or separate propagation subsystem. A focused test widens/narrows several times without redrawing the parent, then resizes and requires the leaf to redraw. **Declared-size changes use ordinary bottom-up propagation.** The changed child and its direct parent remain marked until the parent resolves the new rule. Further propagation depends on the parent's new answer and drawing contract. The blanket ancestor walk, `answer_invalid` set and frame-global `replace_answers` flag are gone. They predated strict bottom-up settlement and original-offer replay; a parent's independent answer no longer invalidates the whole tree merely because a descendant declared a new width. A regression checks the changed geometry and that propagation stops at an independent parent. **The `c44bd19` extent-child extension was removed.** It changed `widget_within` to accept `DrawRegion::Extent` and enlarged retained child records, but saved only 1.8% resize cycles and no primitive writes. The dependency fixes address the major cost with the original `widget_within(UiRegion)` API and widget-id-only inherited-child list. Pad and Stack again read raw placement; those conservative reads are not the cause of the pathological update counts. Instrumented work, seed 1/depth 8, 50 frames per phase (cold once): | phase | draws at c44bd19 | current draws | primitive writes at c44bd19 | current writes | | --- | ---: | ---: | ---: | ---: | | cold | 463 | 484 | 9179 | 9179 | | repaint | 1 | 1 | 1 | 1 | | many | 263.1 | 274 | 5873 | 5873 | | size | 240 | 3 | 5200 | 105 | | scroll | 2 | 1 | 0 | 0 | | resize | 278 | 27 | 5730 | 0 | Resize performs no text renders. Size updates render one text. Removing the extent-child extension increases cold/many container evaluations again. Do not hide those costs behind the large size/resize gains. The original `e44dea3` measured 16 draws/106 writes for size and 13/0 for resize, but does not implement the corrected frame/extent semantics, so it is context rather than a correctness baseline. Nine alternating pairs of direct, uninstrumented release executables compare `c44bd19` with `0e107f0` under `perf stat -e cycles:u,instructions:u`. All samples were retained, with no scans or builds running during measurement. Counts include fixture setup; these are VM CPU measurements, not phone frame times. | phase (frames) | median cycles before → after (billions) | median instructions before → after (billions) | | --- | ---: | ---: | | size (2,000) | 4.0774 → 0.2021 (-95.0%) | 10.4017 → 0.4993 (-95.2%) | | resize (2,000) | 4.7070 → 0.4930 (-89.5%) | 11.9383 → 1.3009 (-89.1%) | | many (2,000) | 4.7718 → 5.0276 (+5.4%) | 12.0567 → 12.4463 (+3.2%) | | repaint (1,000,000) | 2.6803 → 2.9086 (+8.5%) | 6.0224 → 6.4382 (+6.9%) | | scroll (300,000) | 2.6252 → 1.3443 (-48.8%) | 6.5802 → 3.0649 (-53.4%) | All five before/after cycle ranges are disjoint. The main gains coexist with 5.4% more cycles for `many` and 8.5% more for repaint. No claim is made that the prototype is now uniformly faster. Additional depth-6 fixtures, nine alternating pairs and 4000 frames: seed 3 size updates use 20.2% fewer cycles and 23.0% fewer instructions, while its resize case costs 5.4% more cycles and 3.5% more instructions. Seed 13 resize uses 6.6% fewer cycles and 7.7% fewer instructions. Those cycle ranges are disjoint too. Seed 13's size case performs no widget draws and its cycle ranges overlap. Verification: workspace formatting, clippy with `layout-diagnostics`, and tests (106 suite tests, 21 core tests, fast generated cases) pass. Exact release scans pass 2000 seeds/depth 4 and 1000/depth 6; the shrinker passes 400 trees/depth 5, and the debug scan passes 120 seeds/depth 4 with assertions enabled. Every scan runs all fifteen scenarios without a position tolerance. Each of the five new regressions fails when its corresponding behavior is deliberately broken. All five reference renders (`view`, `minimal`, `random`, `tabs`, `text`) match `a7307d9` pixel for pixel. Resizing `tabs` from 1920x1200 to 900x1200 matches a cold render there, also with zero differing pixels. The GPU probe reports Venus on the RX 7900 XT. **Remaining work is narrower, but not proved unavoidable.** Cold layout and widespread content updates still run descendants while evaluating provisional sizes, including children whose answers will never be read. A disposable trial made child requests lazy during measurement and skipped primitive emission, but reused the same active records for measurement and painting. It failed nine oracle scenarios and made dirty updates much worse. Measurement cannot erase or replace the final drawing's dependency/lifetime state. The trial was removed; there is no second widget layout body or measurement phase in this change. The hot wrapping text really does return different heights at 68 and 89.53613 px, so some width-dependent evaluation is necessary. That does not justify repeating it after an unchanged size answer. The resize trace and reduced stack/overlay case distinguished those two situations and led to this fix. Future work on cold/many should preserve that distinction rather than weakening raw-placement checks or memoizing arbitrary width results without a validity model. The earlier glyph-origin composition optimization (`a7307d9`) remains; it changes no layout decisions or retained state. Two removed arithmetic trials remain unpromising: combining frame recomposition with extent movement saved under 0.8% instructions and no cycles; special-casing equal endpoint fractions in `UiSpan::within` cost 11% cycles and 7% instructions. Retained local coordinates still cost memory per primitive. The prototype remains separate from PR #18; the `Inset`/`Outset` changes remain parked. ### Measured against #18's head, and the placement pin behind the gap Checked on 2026-09-17 in `/home/bob/repos/iris-layout-baseline` (`e44dea3`, its own `target-own` because its `target` is a symlink into `/home/bob/repos/iris-pr18`) against `/home/bob/repos/iris-layout-experiment` (`0e107f0`). **The five-phase cycles table above compares `c44bd19` with `0e107f0` -- two commits inside the experiment -- so it says what the last two fixes bought, not what the experiment costs against the branch it would replace.** Against `e44dea3` the picture is different, and it decides whether this can replace #18. Nine alternating pairs under `perf stat`, seed 1 at depth 8, uninstrumented release executables: | phase | cycles `e44dea3` -> `0e107f0` | instructions | | --- | ---: | ---: | | size (2,000) | 0.2618 -> 0.2026 B (-22.6%) | -21.3% | | scroll (300,000) | 2.5169 -> 1.2796 B (-49.2%) | -49.5% | | repaint (300,000) | 0.8546 -> 0.9533 B (+11.5%) | +15.0% | | resize (2,000) | 0.2697 -> 0.4813 B (+78.5%) | +99.0% | | many (2,000) | 2.3885 -> 4.9504 B (+107.3%) | +98.4% | Each phase's before and after cycle ranges are disjoint. The work counters agree with the times: `size` falls from 16 widget draws to 3 and `scroll` from 2 to 1, while `many` rises from 157 to 274 and `resize` from 13 to 27. `many` is also the phase that costs anything at all -- median frames at seed 1, depth 8 are 1 us for repaint and scroll, 9 us for size, 48 us for resize and 667 us for `many`, so a percentage on `many` is worth two orders of magnitude more than the same percentage on repaint. **The `many` regression varies enormously with the tree**, so one fixture cannot settle it. Median frame, 200 frames, `IRIS_DIRTY` at its default: | fixture | `e44dea3` | `0e107f0` | draws before -> after | | --- | ---: | ---: | --- | | seed 1, depth 8 | 0.318 ms | 0.667 ms | 157 -> 274 | | seed 2, depth 8 | 0.218 ms | 0.700 ms | 111 -> 349 | | seed 5, depth 8 | 0.759 ms | 0.702 ms | 337 -> 230 | | seed 13, depth 8 | 1.088 ms | 6.351 ms | 524 -> 2380 | | seed 3, depth 6 | 0.173 ms | 0.156 ms | 83 -> 51 | | seed 13, depth 6 | 0.583 ms | 0.570 ms | 325 -> 268 | **The cause is the raw placement read, and it is nearly all of it.** `LayoutHolds::placement` is `Some(..)` for any widget that called `Painter::placement`, and that pins the exact box the drawing sits in: the widget is redrawn whenever it moves at all, however wide its frame and extent ranges are. `Pad` and `Stack` both read it -- `self.padding.region_of( painter.placement())` and `let placement = painter.placement()` -- so every `Pad` and `Stack` in a row redraws its whole subtree when an earlier sibling changes length. A counter for reuse failures where the frame and extent ranges still hold and only the placement moved says it is 45 of the 109 failures a `many` frame at seed 1 has, 14 of 26 on `resize`, and 73 of 136 on `cold`. Bounded by deleting the `reads_placement = true` line -- unsound, since nothing then redraws a widget whose placement really did move, but it prices the dependency: | fixture, `many` | `e44dea3` | `0e107f0` | no placement pin | | --- | ---: | ---: | ---: | | seed 1, depth 8 | 0.318 ms / 157 draws | 0.667 / 274 | 0.127 / 78 | | seed 2, depth 8 | 0.218 / 111 | 0.700 / 349 | 0.079 / 46 | | seed 13, depth 8 | 1.088 / 524 | 6.351 / 2380 | 0.147 / 84 | `cold` at seed 1 falls from 484 draws to 280 and 12.5 ms to 11.1 ms with it gone, and `resize` from 48 us to 12 us. Read it as an upper bound on what `Pad` and `Stack` having no way to say "inside my extent" was costing, not as what removing the read properly would buy -- see below for the difference. **That is what `c44bd19` was, and the 1.8% that got it reverted was measured before the dependency split.** It is now landed as `e6ba570`, below, and the bound above turned out to overstate it: deleting the read also deletes invalidation that the extent semantics genuinely require, so it prices "no dependency at all" rather than "the dependency expressed properly". ### Landed on 2026-09-17: extent children, and an ordered walk Three commits on `wip/region-and-placement`, pushed, on top of `0e107f0`. **`e6ba570`, a child gets a part of the container's extent.** `widget_within` takes a `DrawRegion`, and `DrawRegion::Extent(part)` gives the child a part of the extent without reading it. What is retained is the part rather than the box it resolved to, so moving the extent re-places the child through the same rule: `inherited_children` became `extent_children`, carrying `Inherit` for the wrapper case `Painter::widget` already had and `Within(part)` for the new one. `Pad` and `Stack` use it and no longer read `placement()`. The dependency that goes up is a range on the container's *extent*, since only the part's length reaches the child. A declared length is unchanged -- it is a length of the frame wherever its box came from. What still pins the placement is a report with a fraction in it, and that pin is on the answer rather than the drawing; the test from the first attempt fails without it. **`3bf2293` and `34cafb6`, the walk takes the deepest mark from a `BTreeSet` keyed by depth** rather than `max_by_key` over the whole set. Every mark made while the walk runs goes through `mark`, which queues itself; the set is still what says the walk is done, so a mark that arrived another way cannot be left for the next frame. Depth reads per `many` frame at seed 1 depth 8: 131/1,314/14,611 at 9/34/145 marks become 57/160/436. What is drawn does not change at any load measured. Ties between equal depths now break by widget id, which makes the walk deterministic. Cycles, medians of seven alternating runs, `perf stat -e cycles:u`, against both the branch #18 would merge and the experiment as it stood: | phase | `e44dea3` | `0e107f0` | head | vs `0e107f0` | | --- | ---: | ---: | ---: | ---: | | `many`, seed 1 | 1.273 B | 2.543 B | 2.502 B | -1.6% | | `many`, seed 13 | 1.323 B | 7.308 B | 5.931 B | -18.8% | | `resize` | 0.269 B | 0.485 B | 0.389 B | -19.9% | | `size` | 0.261 B | 0.205 B | 0.204 B | -0.2% | | `scroll` | 1.730 B | 0.912 B | 0.939 B | +2.9% | Verified at each commit: fmt, clippy with `-D warnings`, 109 suite and 20 core tests, the oracle at 100 seeds, the shrinker at 400 trees of depth 5, 1000 seeds at depth 6 and 2000 at depth 4 over all fifteen cases, and the five reference renders plus `tabs` resized to 900x1200 and `random` to 1280x800, all byte-identical on Venus. ### Where the `many` gap actually comes from (2026-09-17, traced) The two sections above blame the placement pin and then the extent contract. Neither is the cause. Disabling the pin (unsound, a bound) still redraws **487** distinct widgets a frame at seed 13 against `e44dea3`'s 159, and a per-widget trace of one `many` frame shows what does it: **local redraws defer to the parent, and the deferrals chain to the root.** `redraw` refuses a dirty widget whose given box is not as long as its offer and marks its parent instead. Under this protocol that is nearly every widget under a self-sized container. `Span` hands its children its own placement across itself as their *frame* (`UiRegion::from_axis(axis, UiSpan::FULL, *own.axis(!axis))`), and that placement is `FULL` while the span is being measured and its answer once it is placed, so a child's offer frame across the span is the whole window and its given frame is the span's height or width. The trace has 43 deferrals in one frame, in chains such as 424 → 425 → 437 → 449 → 483 → 487 → 491 with `given=(52, 40) offered=(1920, 40)` at every step. Each container reached that way runs its body at both placements, redrawing its subtree at two geometries. `e44dea3` defers under the same rule but its given and offer differ only by the widget's own alignment slack, so its chains stop after a level or two: 19 deferrals, 159 distinct widgets. **Re-asking locally at the offer** is the fix that follows, on branch `wip/local-reask` (one commit over `34cafb6`, pushed). `ActiveData` keeps `offer_region` beside `given_region`; `redraw` draws the widget once in the offer's frame at the offer's lengths and placement, and again at the given box where the two differ. Uninstrumented, 200 frames, seed and depth as before, against `e44dea3`: | phase | `e44dea3` | `34cafb6` | `wip/local-reask` | | --- | ---: | ---: | ---: | | `many`, seed 1, depth 8 | 0.263 ms / 157 draws / 95 distinct | 0.549 / 263 / 108 | 0.499 / 239 / 93 | | `many`, seed 13, depth 8 | 0.896 / 524 / 159 | 4.727 / 1904 / 508 | 1.282 / 647 / 294 | | `size`, seed 1 | 0.016 / 16 draws | | 0.010 / 3 | | `size`, seed 13 | 0.094 | | 0.003 | | `resize`, seed 1 | 0.019 / 13 draws | 27 draws | 0.028 / 22 | | `scroll`, `repaint` | 1 us | | 1 us | With the pin also disabled on that branch (bound), draws fall a further 15% and distinct widgets do not move. **What is left is the offer-then-place double pass**: a dirty container is drawn at its offer placement, where its children get the offer geometry, and again at its placed one, and any child whose `Holds` is a point (`Branch`, `Scroll`, wrapping text at a width it was not measured at) is drawn on both passes. That pass exists because the frame a span gives its children across itself is derived from the span's own answer, so it cannot be the same on both asks. **The branch is not sound.** The suite, the debug oracle, 100 seeds and the shrinker at 400 trees of depth 5 pass; the oracle at 1000 seeds of depth 6 diverges on two. Both reduce to a self-sized container whose answer changes under a local redraw: - seed 532, `reorder`: `Pad{0} > Stack[ Span{RIGHT}[ Wrapped, Wrapped, Rect[x:178px, y:123px] ] ]`, rotating the span's children. Warm places a text at 947..1115 x 536..659, cold at 986..1076 x 483..712. - seed 398, `every-size`: `Span{LEFT}[ Rect, Rect, Stack[ Rect, Branch{ probe: Wrapped, wide: Rect, narrow: Span{DOWN}[ Rect[x:129px, y:leftover] ] } ] ]` with a size rule on the stack. Warm places a widget at 11..183, cold at 6.5..187.5 on x. Reduce them with `SHRINK_SEED= SHRINK_DEPTH=6 SHRINK_CASE=` on that branch before building on it. **The cross-axis frame is the protocol question, not the pin.** Bryan's rule that a widget's frame does not change between the measuring and the placing ask is broken across every span, because the only sensible reference for a child's fraction across a span is the span's own box, and that box is the span's answer. `e44dea3` has the same double pass and pays for it by deferring to the parent; what it does not have is a frame that changes under a widget. Whether to keep frame and placement apart at all turns on this and on nested-span semantics; see the 2026-09-17 assessment handed to Bryan. ## How layout is decided ### Fixed point Decided with Bryan on 2026-09-15. Layout decides on a grid rather than in floats. - **`Fixed` is an `i32` counting `1 / 2^SHIFT`.** Adding and subtracting are exact; `mul` drops to the step below (Bryan, 2026-09-16: truncation is preferable); `div`, `div_int` and `ratio` round to nearest; `to_scale` takes the nearest step. Two routes to one place that land on one number are the same place, so everything downstream compares for equality. - **`Px` is `1/1024` px, `Rel` is `1/2^24` of a box, `Weight` is `1/65536` of a share.** `PX_SHIFT` and `REL_SHIFT` are the only statement of the first two; the shader's copy is prepended from them by `render::module_source`. `Px` was `1/64` first, where one rounding's residue was 0.016 px and enough to move a box. Range is +/-2.1M px and conversion to `f32` is exact to 16,384 px. - A weight is not a fraction: a list divides its room by the total of its weights, and `Rel::ratio` turns two weights into a share on the finer grid. - **Arithmetic wraps** (`4febabf`, Bryan: a coordinate past the range will not draw reasonably anyway, so wrap and break clearly). Saturating cost a twelfth of layout's instructions. `MIN` and `MAX` stand in for an unbounded end and are only ever compared against; `from_f32` is the one operation that clamps, and `Holds` keeps a saturating `narrow`. - A pointer, a wheel notch, a shaped glyph advance and a window size arrive as floats and go on the grid where they arrive. `Vec2` is what the GPU and the platform speak; `PxVec2` is what layout decides in. - **Do not widen the grid to chase a residue.** Every failure this branch saw was one value reached by two expressions, sitting on a boundary defined by the same value coming back the other way. No precision shrinks a residue that is the whole distance. ### A box in pixels is one multiply from its parent's `ActiveData` keeps a widget's box as lengths of its parent's box -- `given_len`, and `offer_len` for the box it was first asked about -- `DrawInfo` carries the pixel lengths themselves (`px`, `offered_px`), and a draw threads them down one `Len::to_px` at a time: the box its parent gave it, then the part of that box its own answer placed its drawing in, which `placed_lens` states once for both `placed_box` and the walk. `Painter::px_size` and `px_len` read that value, and `UiRenderState::asked_px` takes the same steps back up the parent chain when a local redraw starts part-way down the tree. Neither chain has a coordinate frame in it, so a region node cannot break either, and warm and cold reach every length by the same expression. - **`Holds::through` is the exact preimage of `px + floor(rel * box)`**: `floor(rel * B) >= lo - px` is `rel * B >= (lo - px) << REL` and `floor(rel * B) <= hi - px` is `rel * B < (hi - px + 1) << REL`, two `div_toward`s once the sign of `rel` has said which bound is which. The answer is an interval even for a single length, because a floor is not invertible. The range has to contain the box a drawing was made in (the `Holds` assertion in `draw_at`, debug only) and must not contain a box the drawing does not hold for (the oracle); being the preimage makes those one statement rather than a trade-off. - **Symbolic regions are for the GPU, hit testing and remaps alone.** `Moves::resolve` is the only walk left and it is the vertex shader's. Nothing layout decides is composed back up the move chain. - **`px` is not stored on `ActiveData`, deliberately.** A resize every widget's `Holds` admits redraws nothing, so a stored pixel length would be stale on every widget in the tree with nothing to say so. `asked_px` walks up only where a widget is already being redrawn; the mean chain is 2.8 levels. - **The window is not a move entry** (`5b78002`). A chain bottoms out in `MoveIdx::NONE`; the window is applied where a fraction becomes pixels, `to_px(output_size)` on the CPU and the uniform in the shader. A resize rewrites no retained entry and re-uploads nothing but the uniform; its cost is whatever `Holds` redraws. - Failed hypothesis, kept as the shape of the mistake: an offer composed back up the chain fell back to `FULL` under a region node and was resolved against that node's *placed* box, so everything under a `Scroll` was re-asked at the content's width and confirmed its own answer. Pinned by `unsettled::a_widget_under_a_region_node_is_asked_in_the_box_that_node_was_offered`. The old chain with an allowance in `through` passed that case and the old chain with the exact `through` failed it; both halves had to land at once. ### What the fuzzers tolerate The frame/extent continuation removes `AGREE_STEPS`: warm and cold pixel regions must compare exactly. This is distinct from the equal-share test: when a row's grid-step count is not divisible by the number of children, individual share widths can differ while every rerun of that layout must still agree exactly. The PR #18 baseline still allows two position steps; its earlier failure at one step (`resize-size`, seeds 384 and 162 at depth 5) is a useful regression target for the experimental recomposition. ## Retained-layout invariants - `Holds` is the interval of box lengths for which a widget's drawing and reported size stay valid. Reading `Painter::px_len` or `px_size` narrows it to the length read; `Painter::holds` widens it. Parent validity is the intersection of what its children induce: measured children for an answer, all painted children for a drawing. The contract is trusted: a widget declaring a wrong range is a defective widget, and Iris adds no defensive work to recover from one. - A retained drawing can be reused only when its `Holds` contains the new pixel box on both axes, its parent node is unchanged, its region-node choice matches the retained structure, it is on the layer it is asked for, and the widget is clean. A valid ordinary subtree moves without redrawing by recursive remap; a region node moves by one entry. - **A retained drawing belongs to the layer it was made on.** A container that measures a child by drawing it measures on the layer that child will draw on -- `Painter::child_layer_at` -- or it pays two draws a frame. - The first box a parent asks about is the offer; a later box chosen from the answer is the final box, not another answer. A dirty widget is re-asked in the box its parent gave it, and only where that box is as long as the offer; anything else is its parent's question, with the mark left on. Lengths and not whole boxes: what a drawing depends on is its lengths, so the same lengths elsewhere is the same question. - An answer is reusable where its measurement contract holds. Its final drawing is checked independently and may need redrawing even while the answer stands. Drawing validity is translated back through the chosen placement for the parent's drawing contract, not intersected into the retained answer. - `Painter::widget_at(child, frame, placement)` supplies an optional chosen extent on each axis. Unchosen axes place the measured answer by the child's alignment. `measure_len` leaves a fresh measurement at the offer until the parent assigns a slot; it does not first place an intermediate answer. - Placement must not replace the fraction reference. The failed earlier attempts moved `ActiveData::region` with the drawing and made local redraws ask differently rounded questions. The experiment retains `placement` separately and replays original local frames when recomposing geometry. - A widget that clips to its box reports its box: `Scroll` and `Masked` report `LEFTOVER` on both axes, and a `debug_assert` holds any widget that set a mask this draw to it. Overflowing is otherwise ordinary, which is why the assertion is narrowed to mask-setters. Where content shorter than a `Scroll`'s viewport sits is the scroll's own alignment, and its "fits at the start of any box" widening is gated on near alignment. - **A widget's own mask is not the one it inherited.** `ActiveData` keeps both; they differ exactly where the widget called `set_mask`, which says whose mask a move rewrites, and a local redraw is handed the inherited one. Pinned by `retained::a_masked_widget_redrawn_on_its_own_sets_its_mask_again`. - A span is as long across itself as its longest fixed child, unless a rule gives that length outright (`Painter::has_exact_size`), in which case it does not read its children there at all. Any relative or `leftover` child makes it report `leftover`. **Do not choose between a fixed and a relative child in pixels at the span's current width**: that admits multiple self-sizing fixed points, and generated seed 13 settled differently warm and cold under it. The same circularity is what a cap containing `leftover` would put into `SizeRule::Max`. - `Span`'s leftover/no-leftover split is a strict layout decision, not a rounding tolerance: its `Holds` range must use the same exact boundary as drawing. A tolerant endpoint retained zero-height children in seed 16; a boundary moved off where boxes land was needed in floats and is not on the grid. **A structural decision may not be taken on a hair's breadth** that two routes can disagree about. Pinned by `unsettled::a_box_that_only_rounds_past_its_fixed_children_leaves_nothing_over`. - A pixel comparison is equality. A length given in pixels is that many pixels wherever it ends up, structurally: `Len::within` adds a part's own pixels rather than scaling them. Pinned by `a_length_in_pixels_is_that_many_pixels_however_it_is_nested`. A length given as a share is not: equal shares come out one or two steps apart because positions, not lengths, are what gets rounded, so the row fills and no two children leave a seam (`equal_shares_differ_by_at_most_two_steps_and_fill_the_row`). - **A move that keeps a box's length is a translation, and exact.** A box that changed length re-expresses each part as a fraction of the new one, which rounds. This inverts the float-era rule; `tests/cases/drift.rs` pins that the grid does not drift either way. - `Scroll` must return the answer from the first box it asked about, whether retained or fresh; returning the final placed answer advanced one fixed-point iteration (seed 86). Content that fills the viewport unscrolled is handed back as it came, because the same box written as its own length in pixels does not round alike. - An asked-but-undrawn size dependency names the widget that asked as its parent (seed 10). `Painter` records size-dependency edges only when a parent reads a child's size or hint; an undrawn measured child stays recorded so a later change reaches whoever decided not to draw it. - Dirty widgets settle deepest-first, including during resize. A deferred child leaves its parent marked, so no clean answer can hide an unsettled size dependency. `dirty_size_under` has been deleted. - Declared non-`leftover` lengths are resolved by the parent where the widget is drawn, so a declared-length change redraws the parent. A rule wins on the axis it names and the widget under it never learns of it. **A cap may not contain `leftover`**: a cap must read the report, so rule and report are one equation, and a share puts the row's division into it -- the multiple-fixed-point failure again. A cap is pixels and a fraction, which is what `Len` is. - Text shaping is retained separately from line breaking; a greedy break holds from its longest produced line through the width it was made at, reported through `Painter::holds`. - Region nodes: a node holds a whole `UiRegion` in its parent node's coordinates, `FULL` is the identity, widgets opt in with `.region_node()` or `Widgets::set_region_node`, and changing it redraws the subtree once. `.scrollable()` sets it once; raw `Scroll::new` does not. A removed node's move entry stays alive until every descendant has migrated. `Span` and `Align` add no nodes. - Alignment is one `f32` per axis (Bryan, 2026-09-15), default the middle on both because the edges assume a direction. One widget keeps one length per axis; a second length needs a second widget, `Wrapper` via `.wrapper()` (Bryan, 2026-09-16, `d21a215`). ## Verification at the current head At `e44dea3`: - `cargo fmt --all --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`: green, 92 suite tests, 19 core unit tests, 11 generated cases. Only the long runs and the profiling rigs are ignored; no known defect is. - The release oracle at 100 seeds in 14.2 s, and **120 seeds in debug** in 59 s -- the debug run exercises the `Holds` assertion in `draw_at`. - All fifteen shrinker cases at 400 seeds of depth 5 in 57 s, the oracle at 1000 seeds of depth 6 in 143 s, and **2000 seeds at depth 4 over all fifteen cases** in 260 s. The last is not routine and should be: it is the only run that has ever found anything past seed 400. - `view`, `minimal`, `random`, `tabs` and `text` byte-identical at 1920x1200 against `25e456e`, as is `tabs` under the recorded replay. `random` live-resized from 1920x1200 to 1280x800 is byte-identical to a cold 1280x800 render. Rendered through Venus on the host's RX 7900 XT, confirmed against `vulkaninfo --summary` in the same session -- an llvmpipe fallback makes the same PNG and nothing in it says so. - All rig counters identical on `cold`, `repaint`, `many`, `size` and `scroll` across `25e456e`. `resize` gains one queue pop, one local redraw and one depth read and loses one failed reuse attempt, which is the root going through the walk; drawn widgets, widget draws, draw requests and primitive writes do not move. Everything above is verification of what was changed, not a claim that the branch is correct. **A claim about a render holds for the commit it was checked at and no further.** `tabs` changed twice across `d3b0ebf` with nobody looking; take the oracle as the reference and the five renders as a spot check. **Run the long two before believing a rounding change**, and run the ordinary suite in debug: ```sh cargo test --release --test generated -- --ignored a_long_run_of_seeds_agrees 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 ``` Depth is what finds things, but so is breadth: every late defect before 2026-09-17 surfaced at depth 5 or 6, and the two open ones were found by running 2000 seeds at depth 4, which nothing routine does. Widen one axis at a time and record which. ## Performance **Threading a box in pixels down the draw is free on cold layout and 9-13% off the retained paths** (2026-09-17). Instructions:u, medians of 21 runs of binaries built in one worktree, seed 1 at depth 8, against `5b78002`: | phase | before | after | | | --- | --- | --- | --- | | `cold`, 200 frames | 313.1M | 312.9M | -0.04% | | `resize` | 408.1M | 405.6M | -0.61% | | `many` | 1,924M | 1,756M | -8.75% | | `scroll` | 357.3M | 323.4M | -9.49% | | `repaint` | 363.3M | 315.4M | -13.18% | `cold` and `resize` compare directly: all twenty-five work counters are identical. The other three do less work: `repaint` goes from 23 draw requests and 13 widget draws to 1 and 1, because `redraw` composes nothing and a widget whose box moved without changing length settles itself instead of escalating. ### How to measure here - **Check the work counters before comparing two commits' times.** The rig prints drawn widgets, widget draws and primitive writes; a comparison is only worth reading when they match. `random.rs`'s `Branch` picks a subtree by a measured pixel length, so the fixture's shape moves with the thing measured; `Edits::fixed_branches` pins it for timing and the oracle keeps measured branches on purpose. A 3x this section once reported was that artifact. - **`perf stat` in this VM returns garbage readings** for both `instructions:u` and `cycles:u`, roughly a quarter of the time, off by a factor of five to fifteen. Take medians of nine or more and report how many readings a filter kept. Cycles spread 1-3% between sets of one unchanged binary and 6.7% in the worst; instruction counts hold to 0.02% within a binary and move 0.5% across a rebuild, so build the baseline beside the thing measured and quote a delta. `ex_div_busy` held to 0.1%. - **What moves cycles is whether `UiSpan::within` inlines.** It is the hottest line in layout; `nm` shows it as a symbol when it does not. Shrinking its body until the inliner takes it won; `#[inline]` on the body it had lost 1.5% cycles. Shrink it, do not annotate it. - `Holds::through` divides twice per call and accounts for essentially all of a run's `i64` divisions: 21.3M cycles of a 500-frame `many`, 2.8%. The float head divided twice there too. ### Tried and rejected, with numbers - A float reciprocal for `AxisRemap::apply_scalar`'s division: +6% cycles. `Holds::through`'s division has not been tried. - Branchless `shift_round`: +6.7% cycles alone, and worse again with the short-circuits removed. Size, not the branch, is what keeps `within` out of line. - Removing the per-child hash lookup in `remap_subtree`: 0.0%. - Short-circuiting `apply_scalar` where the fraction is nought or one: +17%. - Short-circuits guarding a saturating multiply stopped paying once the multiply wrapped. Re-price a short-circuit before keeping it. - Rust does not contract `a + b * c`; the float head never had an FMA to compare the grid's multiply against. Wrapping (`4febabf`) was -8.6% instructions and -6.6% cycles. Truncating (`08c9d5a`, with `Fixed::scaled`'s zero test and `within`'s `is_full` tests removed as one commit, since they are worth 61M instructions apart and 115M together) costs a share a thousandth of a pixel of its row, makes a flipped span sit a step from its mirror, and moved an antialiased edge in `tabs` by one pixel. See "Where the residual comes from" for what that last one is. ## Rigs and reproduction Ordinary framework verification: ```sh cd /home/bob/repos/iris-pr18 cargo fmt --all --check cargo clippy --workspace --all-targets -- -D warnings cargo test --workspace ``` The ordinary tests are modules of one `tests/suite.rs` target; pick a module with `cargo test --test suite layout::`. `profile.test` uses `debug = "line-tables-only"`, which halved the test-target rebuild. `tests/generated.rs` compares a warm incremental tree with a cold tree of the same state; `IRIS_GENERATED_SEED`, `IRIS_GENERATED_SEEDS` and `IRIS_GENERATED_DEPTH` select what it covers. `tests/shrink.rs` reduces a failing tree over the same fifteen cases and the same trees -- `iris::random::plan(seed, depth, &edits)` and `build(rsc, &plan)`, so a failing seed reduces directly and the oracle prints the command: ```sh SHRINK_SEED=18 SHRINK_DEPTH=6 SHRINK_CASE=repaint-some \ cargo test --release --test shrink -- --ignored --nocapture ``` The cases live in `tests/scenario/mod.rs`, included by both targets by `#[path]`; a case only one rig knows is how the two drifted apart once. Turn what the shrinker finds into a test of its own rather than leaving a seed as the record. Both fuzzers take a thread per core but one. A `git bisect` once named a commit that could not be the cause; read the tree rather than the bisect when that happens. `tests/layout_diagnostics.rs` is the retained CPU rig: `IRIS_PHASE` selects `cold`, `many`, `repaint`, `size`, `scroll` or `resize`, the `layout-diagnostics` feature gives the explanatory counters, and an uninstrumented release binary under `perf` gives totals. Dump the counters with ```sh IRIS_SEED=1 IRIS_DEPTH=8 IRIS_FRAMES=500 IRIS_PHASE=many \ --ignored --nocapture \ | grep -E '^ +[a-z].*[0-9.]+$' | grep -v ' ms$' | sort ``` and `diff` two runs; identical output is what says a change is free. The float head is checked out at `/home/bob/repos/iris-float-cmp`, at `5ed9e87` with `Edits::fixed_branches` applied uncommitted. Its counters do not match the grid's and will not, so a comparison against it is a bound rather than a measurement. The headless reference set runs one process at a time because the rig reuses one compositor; comparison worktrees need 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 replay used for the reference check: ```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 ``` ## Next In order, from the review above and Bryan's steer (2026-09-17): 1. ~~A resize marks the root and goes through the walk.~~ Landed as `a0693ac` and `e44dea3`; see the two sections above. The re-parenting half turned out to be two defects rather than the predicted one, and neither was the `depth()` assertion. 2. **Transparent frames**, the plan near the top of this document. This is the fundamental change and comes before anything built on the placing ask; it subsumes the parked `Inset`/`Outset` and stack-fraction branches (their tests land with step 7 and 8) and the `wip/local-reask` branch, which is superseded and should be deleted once step 6 passes. 3. Write `ActiveData::answer` in one place. 4. Keep the `DrawInfo` on `ActiveData`; delete the copied fields and the reconstruction in `redraw`. 5. **Round on the CPU and snap to the nearest pixel in the shader**, as one change with one verification. Bryan approved the snap on 2026-09-17 (rendering may change wherever it brings the screen closer to what the user's code says: three equal sections of 1000 px need one of them rounded up), and to CPU rounding the same day. The reason for that one is different: a `Rel` is off by at most `2^-25` of its box, so with round-to-nearest every product whose true value is a whole number of steps is exact for boxes under about 8,000 px, where truncation leaves half of them one step short and layout then decides "does not fit" on a container the user meant to fit exactly. Use the branchless round-half-up form, `(a * b + (1 << (BY - 1))) >> BY`, not the sign-branching `shift_round`; re-derive `Holds::through` for `round` (its two shifted bounds move by half a `Rel` step); check with `nm` that `UiSpan::within` still inlines; expect a couple of percent of instructions and re-run the long fuzzers and the render set once for both. 6. The smaller items: the stale `f32` comment, the gap of an undrawn child, confirm nested `leftover` weights, one zero-divisor fallback. Add to them: a span that overflows itself hands a child a box of negative length, which is ordinary now rather than a corner, and nothing states what a widget may assume about one. 7. `LazySpan`, the next LAYOUT.md §2 item. Region nodes cover the movable subtree case; do not restore a separate child-placement API. 8. `SizeRule::{Min, Max, Clamp}`, restoring the `max_width`/`max_height` builders `8220a78` deleted. The clamp boundary is a hard layout decision with an exact `Holds` split at the crossover, both sides in `Px`. Still awaiting Bryan: whether a `Max` narrows the box the child draws in, or only what the parent reports for it. 9. `Scroll` taking a direction rather than one axis. `docs/LAYOUT.md` §4, §5 and the density section are stale: they name `Painter::place`, `SetSize`, `desired_width`, `apply_rest`, `Len::dp`, `Aligned` and `MaxSize`, none of which exist. Do not restore `OnResize::Translate` or `OrthoSize`. Other queued work, in dependency order: `UiRenderState` behind `Rc>`; density-independent pixels; input restructuring (pointer capture, drag slop and axis, cancellation, mask-aware hit testing, timestamps); retained paints, selection, overlays and shared runtime state; generic desktop/Android hosts and reusable example/APK tooling; application-owned fonts and replaceable glyph-atlas buckets; positioned text overflow and cluster-safe ellipsis. The archive is a reference, not a patch: it predates returned `Size`, the current box chain and the current length types. Recreate changes on current types and keep app/session concepts out of Iris.