diff --git a/docs/IRIS_EXTRACTION_HANDOFF.md b/docs/IRIS_EXTRACTION_HANDOFF.md index e1af0b3..8dc59de 100644 --- a/docs/IRIS_EXTRACTION_HANDOFF.md +++ b/docs/IRIS_EXTRACTION_HANDOFF.md @@ -6,202 +6,28 @@ Not a decisions log; delete it when the extraction is done. ## Where things stand Canonical `main` is **`ca2b4b2`** (#17, the headless rig). Sixteen slices are -in; #16's size work and #17's rig both merged on 2026-09-14. +in. -**#18 `split/18-position-chain`** is open, worktree `/home/bob/repos/iris-pr18`, -head `4178dfb`, eleven commits. LAYOUT.md §2's O(1) subtree movement, the -`Remap` retirement, and then the three changes the owner chose on 2026-09-14: -the slot carries a box, slots are opt-in, and a widget's region is held in the -coordinates of the slot it draws in. +**#18 `split/18-position-chain`** is open and finished apart from one decision: +worktree `/home/bob/repos/iris-pr18`, head `4178dfb`, twelve commits, 49 tests +passing, fmt and clippy clean. It is LAYOUT.md §2's position chain, generalised +to boxes. `/home/bob/repos/ai-app-2` is on `rustify`, worktree clean. -- **A slot holds a box, not a translation** (`1f9dc48`), given in the - coordinates of the slot it names, and `prelude.wgsl` composes the chain with - `within` instead of adding a delta. A translation is the special case where - the box has its parent's relative extent. Measured against the translate - slot on the same binary: +0.5-0.8% at depth 1-4, +9.6% at 8, +32.2% at 64. - Free where opt-in slots put the chain. The identity is `UiRegion::FULL`, not - zero -- a zeroed entry collapses its subtree to a point. -- **`Painter::place` is how a container asks for a slot** (`d989691`): it - draws a child it decides the box of and may decide again. `Span`, `Aligned` - and `Scroll` use it; everything else shares its nearest ancestor's slot, - which `tests/layout.rs` pins at one deep with four widgets in between. - Placing a child again is one entry to write, moved or resized alike. -- **Nothing is inverted any more.** `UiRegion::stretch`, `stretchable` and - `UiScalar::stretch` are gone, with the case they could not express: a - 40-tall row now stretches on its other axis, which `stretchable` refused. -- **`redraws_under` is the CPU walk that remains**, and it is a question - rather than a marking: before keeping a drawing, ask whether anything under - it would have to be drawn again for the new length, and give up the reuse if - so. Marking instead does not terminate -- the mark escalates to the - descendant's size reader, which re-places the child, which marks it again. - A part of a box with no relative extent holds its contents as offsets, and - composing into it leaves none either, so the walk stops where a length did - not change: an 80-wide child of a widened row is never asked. -- **`Span`, `Pad`, `Stack`, `Offset`, `Aligned`, `SetSize` and `LayerOffset` - say `Scale`.** Each places in fractions and offsets of its own box and none - reads its pixel length. `Scroll` and `MaxSize` do read pixels and stay - `Redraw`. Measured as not the cause of the divergence below: taking every - one of these claims back out leaves the failing set identical. -- **`OnResize::Scale` keeps its name.** The owner rejected `Stretch` on - 2026-09-14: stretch has an opposite and scale does not, and the answer is - per axis so the axis is already established where it is read. - -**The divergence the random trees found, and what it was.** A warm tree did -not always land where a cold build does -- 30 of 90 cases, against 7 on -`db1751f`. Two causes, both fixed in `b0f9f04`, and both about deciding -whether a drawing is still valid: - -- **A region is a fraction of a slot's box, so an unchanged region is not an - unchanged box.** `try_reuse` compared regions, and a child drawn at - `UiRegion::FULL` of a slot whose box had just halved compared equal to - itself and was reused without being descended into, leaving a wrapping text - shaped for a width it no longer had. `ActiveData::px` now holds the pixel - size of the box the widget drew against and the comparison is against that - -- the question that was being asked all along, and right through a slot - change and an output resize alike. -- **A size the parent learnt by drawing the child is an answer for that box - only.** The walk skipped a child whose own box was a fixed width, correctly - -- but that width was what the child reported when the span drew it in the - span's box, and the span's box had changed. A child whose size the widget - read is now redrawn unless it declares an exact `size_hint` for the changed - axis, which is the one case the parent did not draw it to find out. - -90 of 90 now agree, and the ignored sweep agrees over 300 checks on 100 -seeds. The cost of the second one: a size-reading container gives up its reuse -when its box changes length, which is every span, so `OnResize::Scale` earns -its keep on moves and on subtrees whose sizes nobody read rather than on every -stretch. `replace_cost` still measures the case the chain was built for. - -**Render verification, done last as the owner asked.** `view`, `minimal` and -`text` are byte-identical to `upstream/main` at 1920x1200. **`tabs` is not**: -1,283 pixels of 2.3M (0.06%), two one-pixel-wide panel edges shifted by a -pixel, at x=1056 and x=1337. Composing a position through the chain on the GPU -associates the arithmetic differently from collapsing it on the CPU, so a -value that used to land exactly on an integer now falls the other side of the +**The one thing waiting on the owner.** `tabs` at 1920x1200 is no longer +byte-identical to `upstream/main`: 1,283 pixels of 2.3M (0.06%), two +one-pixel-wide panel edges shifted by a pixel, at x=1056 and x=1337. `view`, +`minimal` and `text` are identical. Composing a position through the chain in +the shader associates the arithmetic differently from collapsing it on the CPU, +so a value that used to land exactly on an integer falls the other side of the shader's `floor`. The CPU and the GPU still agree with each other -- both walk the chain bottom-up -- so hit testing matches what is drawn; what changed is -only the comparison against the old code. Matching it exactly would mean -composing root-down in the shader, which needs the chain collected into an -array first. **Worth the owner's call before merging**, since byte-identical -against `upstream/main` has been the bar for every slice so far. +only the comparison against the old code. Matching it exactly means composing +root-down in the shader, which needs the chain collected into an array first. +Byte-identical against `upstream/main` has been the bar for every slice, so +this is hers to accept or to spend a commit on. -A live resize does land where a cold start does, byte for byte, on `tabs` and -`text` -- the check that caught both of #16's defects. `run-headless.sh ---resize WxH@Hz` is that check now. Run one at a time: the rig reuses a single -compositor and a single output, so two at once resize each other's window and -screenshot the wrong thing. - -**Rigs added.** `iris::random` grows a seeded tree -- spans in every direction -holding two to four children, stacks, padding with each of its four sides its -own number, rects with varying opacity, text both wrapping and overflowing, a -declared size over half of it, stopping at a depth. `examples/random.rs` draws -one (`IRIS_SEED`, `IRIS_DEPTH`), and `tests/generated.rs` grows each seed twice --- once and then changed, once with the change built in -- and compares every -widget's box. Eight scenarios: a size change, a resize, both, and five ways of -changing what a span holds (every other child out, all but the first out, -three on at once, the first out and three on, one out of the middle and one on -the end). The ignored sweep is 100 seeds across all eight, 800 comparisons. - -Two things it needs that are easy to get wrong. Three spare leaves are grown -beside every span whether they end up in it or not, and detached children are -held until the comparison is over: a tree that makes fewer widgets, or frees -one whose id is then handed to the next, stops lining up index for index and -every comparison after the first difference is against the wrong widget. And -each shuffle asserts the tree actually changed before comparing, so a case -that quietly did nothing fails rather than passes. - -### The plan after #18, agreed with the owner on 2026-09-14 - -Three changes, done together as one slice on top of #18, because each decides -the shape of the others. - -- **Slots are opt-in, created by the container that re-places children.** A - draw call asks for one; a widget that did not asks nothing and composes into - its nearest ancestor's slot. #18 gives every widget a slot, which puts a - primitive's walk at full tree depth for no benefit, since almost every slot - is zero. `Span`, `Scroll` and `Aligned` re-place children after drawing - them and are the ones that ask. That keeps the chain 2-4 deep, which - `chain_cost` measured as free. -- **A stretch is a rewalk from the widget whose box changed, not a flat pass.** - #18's `stretch` takes the top widget's old and new window boxes and rewrites - every descendant against that one pair, which works only while a composite - fraction of the top box is recoverable from a window rel -- and a - fixed-length box destroys that for everything beneath it, whatever is - stored. Instead: keep the parent-relative region -- the argument - `widget_within`, `primitive_within` and `set_mask` already receive -- on - each widget, primitive and mask, and recompose it against the new box. For - each child, compute its new window box from its local; the same length on - both axes is one slot write where it has a slot and a recomposition where it - does not, and a changed length asks that child's `on_resize` and recurses or - redraws. Nothing is inverted, so `stretchable`, `UiScalar::stretch`'s - division and its debug assertion all go. It also fixes two things #18 gets - away with only because nothing but leaves says `Scale`: `try_reuse` asks - `on_resize` of the top widget alone, and `stretch` never rewrites - `Mask::region`. -- **Containers say `Scale`.** `Span` on both axes: it hands every child - `UiSpan::FULL` on the orthogonal axis, and its aligned-axis placement is - written in fractions and offsets of its own box, so the children's local - regions do not change when the box does. A child's *length* can, if one - without a hint reports differently after a redraw, and that reaches the - span through `size_deps` as it does today. The general rule: a widget is - `Scale` on an axis unless its draw reads the pixel length of its box on that - axis, which is text. The default stays `Redraw`. - -**Open, and the owner's to decide: a slot that carries a box instead of a -translation.** Everything under a slot would store its region relative to -that box, and the shader would compose the chain with `within` instead of -adding deltas. A move and a stretch of a `Scale` subtree are then both one -slot write: no rewalk over primitives, no stored locals, and `Translate` comes -free, since a glyph at an abs offset from its row's start stays there when -the row grows. The CPU work on a resize becomes asking `on_resize` down the -widgets and redrawing the ones that say so -- one ask for a transcript row -against a few hundred glyph rewrites. It replaces #18's chain rather than -extending it, and it contradicts LAYOUT.md §2's line that slots carry -translation only -- a line an agent wrote into the design on 2026-09-04, not -something the owner asked for. Her own statement of the requirement was "if it -needs to be moved then that can be done after the fact efficiently, or resized -just done after as well". - -**Measured, 2026-09-14.** `MoveOffset` widened from a `Vec2` to a `UiRegion` -(36 bytes) and `resolve_move` composing with `within` instead of adding -deltas, against the same binary's translate slots: depth 1 78.5 us against -78.0, depth 2 78.7 against 78.3, depth 4 79.6 against 79.0, then depth 8 89.8 -against 81.9, depth 16 128.6 against 111.3, depth 64 331.2 against 250.6. So a -box slot is free at the 2-4 depth opt-in slots produce (+0.5-0.8%) and costs -10-30% past depth 8, where the chain is already expensive. All 42 tests pass -against the composing shader and `tabs` at 1920x1200 is byte-identical, -because composing through a translation box is the same map as adding its -delta. The experiment is `stash@{0}` in `/home/bob/repos/iris-pr18`. - -The slot has to carry the whole box rather than a scale and an offset: a -pixel-space affine map scales everything under it, including a child that must -keep its pixel length, and the `rel`/`abs` pair is exactly what distinguishes -the two. What a box slot does not buy is a shorter CPU walk. Asking each -widget whether its drawing depends on the length that changed is the same walk -either way; what the box removes is the region rewrite and the upload at the -widgets that answer `Scale`, and the inversion at the ones that do not. - -What is settled either way: recalculating rather than repositioning is in, as -the `Remap` retirement above. It costs the per-axis carry: a box that changed -length on one axis and not the other is redrawn rather than remapped. Six of -`tabs`'s fourteen relocations and five of `text`'s sixteen, all single- -primitive leaves, and one extra redraw per frame on `replace_cost` -- -354,310,889 instructions against 354,272,387, noise. The rewalk restores that -carry, and generalises it to a subtree. - -Measured, so the next attempt is compared rather than argued: - -| rig | what it says | -| --- | --- | -| `tests/chain_cost.rs` | GPU pass time by chain depth at 200k instances: free to depth 8 (+5%), then ~3 us per level -- +42.6% at 16, +221% at 64. Each step is a storage load addressed by the previous one, so it is the chaining that costs, not the arithmetic at a level; a slot carrying a whole region should measure the same, but has not been. | -| `tests/replace_cost.rs` | Instructions per frame re-placing 200 rows: 1.98M writing each row's slot, 2.38M rewriting its regions, 7.13M redrawing it. A load for `perf`, not a check. Five primitives per row; the regime that decides whether the chain is worth it is a transcript row of a few hundred glyphs, so re-run it with 200 characters of text per row before concluding. | -| `tests/draw_cost.rs` | Pre-existing: what recording a frame costs on the CPU by layer count. | - -Irrelevant at an example's couple of hundred primitives; a transcript's glyphs -are tens of thousands, which is the regime `chain_cost` measures. - -Check for a review before starting anything, and read the newest -`submitted_at` rather than the first result: +Check for a review before starting anything, and read the newest `submitted_at` +rather than the first result: ```sh TOKEN=$(cat ~/.config/gitea/token) @@ -215,7 +41,165 @@ curl -s -H "Authorization: token $TOKEN" \ ``` My replies are ordinary issue comments on the same PR and say what each change -was for. `/home/bob/repos/ai-app-2` is on `rustify`, worktree clean. +was for. + +## How a position resolves now + +Invariants, not history. Everything in `core/src/ui` rests on them. + +- **A slot holds a box, in the coordinates of the slot it names.** A primitive + instance and a mask each name one, and `prelude.wgsl` composes the chain with + `within`. A translation is the special case where the box has its parent's + relative extent. The identity is `UiRegion::FULL`, **not zero** -- a zeroed + entry is a box of no extent and collapses its subtree to a point, which + `MoveOffset`'s comment says beside the `Zeroable` that `Pod` requires. +- **A slot has to carry a whole box** rather than a scale and an offset: a + pixel-space affine map scales everything under it, including a child that + must keep its pixel length, and the `rel`/`abs` pair is exactly what + distinguishes the two. +- **Slots are opt-in.** `Painter::place` draws a child whose box its parent + decides and may decide again, and that child gets a slot; `widget` and + `widget_within` do not, and share the nearest ancestor's. `Span`, `Aligned` + and `Scroll` place. This is what keeps the chain 2-4 deep rather than full + tree depth, which is the difference between free and +42.6%. +- **A widget's region is held in the coordinates of the slot it draws in**, so + a placed widget draws against `UiRegion::FULL` and its box lives in its slot. + `ActiveData::region` is the box it was *offered*, in its parent's slot + coordinates, and `ActiveData::parent_move` is the slot that is in; + `window_region` composes the one through the other, which is the walk the + shader does. +- **Nothing inverts a lerp.** `UiRegion::stretch`, `stretchable` and + `UiScalar::stretch` are gone. A box that changed length is written to its + slot and the descendants recompose against it, which also covers the case the + old guard refused outright: a fixed length has no fraction to recover, so a + 40-tall row could not be stretched on its other axis at all. +- **Reuse is decided on the box a widget drew against, in pixels** + (`ActiveData::px`). A region is a fraction of a slot's box, so an unchanged + region is *not* an unchanged box -- a child drawn at `FULL` of a slot that has + since halved compares equal to itself. This is the check everything else + rests on; do not weaken it back to comparing regions. +- **A size the parent learnt by drawing the child is an answer for that box + only.** `redraws_under` redraws a child whose size the widget read unless it + declares an exact `size_hint` for the changed axis -- the one case the parent + did not have to draw it to find out. The cost is that a size-reading + container gives up its reuse when its box changes length, which is every + span, so `OnResize::Scale` earns its keep on moves and on subtrees whose + sizes nobody read rather than on every stretch. +- **`redraws_under` is a question asked before reusing, never a marking.** + Marking descendants for redraw instead does not terminate: the mark escalates + to that descendant's size reader, which re-places the child, which marks it + again. Asking first and giving up the whole reuse adds no marks and stops. +- **The walk stops where a length did not change.** A part of a box with no + relative extent on an axis is a fixed length held as offsets from that box's + start, and composing anything into it leaves no relative extent either -- so a + widget whose own box did not change length has no descendant whose box did. + An 80-wide child of a widened row is never asked. +- **`Span`, `Pad`, `Stack`, `Offset`, `Aligned`, `SetSize` and `LayerOffset` + say `Scale`**; each places in fractions and offsets of its own box and none + reads its pixel length. `Scroll` and `MaxSize` read pixels and stay `Redraw`, + which is the general rule: `Scale` on an axis unless the draw reads the pixel + length of its box on that axis. The default stays `Redraw`. +- **`OnResize::Scale` keeps its name.** The owner rejected `Stretch` on + 2026-09-14: stretch has an opposite and scale does not, and the answer is per + axis, so the axis is already established where it is read. + +## Measured, so the next attempt is compared rather than argued + +| rig | what it says | +| --- | --- | +| `tests/chain_cost.rs` | GPU pass time by chain depth at 200k instances. Translate slots: free to depth 8 (+5%), then ~3 us per level, +42.6% at 16 and +221% at 64. Each step is a storage load addressed by the previous one, so it is the chaining that costs, not the arithmetic at a level. A box slot (36 bytes) against a translate slot on the same binary: +0.6% at depth 1, +0.5% at 2, +0.8% at 4, then +9.6% at 8 and +32.2% at 64 -- free where opt-in slots put it, and dear only where the chain already was. | +| `tests/replace_cost.rs` | Instructions per frame re-placing 200 rows: 1.98M writing each row's slot, 2.38M rewriting its regions, 7.13M redrawing it. A load for `perf`, not a check. Five primitives per row; the regime that decides whether the chain is worth it is a transcript row of a few hundred glyphs, **so re-run it with 200 characters of text per row before concluding anything from it**. | +| `tests/draw_cost.rs` | What recording a frame costs on the CPU by layer count. Dispatch per list is 6 instructions, 0.1% of a frame at 256 and at 1024 layers. | + +A chain is irrelevant at an example's couple of hundred primitives; a +transcript's glyphs are tens of thousands, which is the regime `chain_cost` +measures. + +## The random trees + +`iris::random` grows a seeded tree -- spans in every direction holding two to +four children, stacks, padding with each of its four sides its own number, +rects with varying opacity, text both wrapping and overflowing, a declared size +over half of it, stopping at a depth. `examples/random.rs` draws one +(`IRIS_SEED`, `IRIS_DEPTH`). `tests/generated.rs` grows each seed twice -- once +and then changed, once with the change built in -- and compares every widget's +box across eight scenarios: a size change, a resize, both, and five ways of +changing what a span holds. `a_long_run_of_seeds_agrees` is the ignored sweep, +100 seeds across all eight, 800 comparisons, about four minutes. + +The property is that laying a tree out again lands where growing it cold does, +which is the same thing as layout being a function of the state. It earned +itself immediately: it found the non-terminating marking, the pixel-box reuse +check and the measured-size rule above, none of which the hand-written tests +reached, and it says the result is better than what it started from -- 90 of 90 +against 83 on `db1751f`. + +Four things about it that are easy to get wrong: + +- **Both trees must make the same widgets in the same order.** Comparison is + index for index, so a tree that makes fewer widgets, or frees one whose id is + then handed to the next, stops lining up at the first difference and every + comparison after it is against the wrong widget. Hence three spare leaves + grown beside every span whether they end up in it or not, and detached + children held until the comparison is over. +- **Attaching a spare moves it.** A widget belongs to one parent; + `WeakWidget::upgrade` registers an add and panics with "cannot add a widget + twice", so it is for a handle that was never added, not a second share. +- **Each shuffle asserts the tree actually changed** before comparing, or a + case that quietly did nothing passes green. +- **A failure prints the widget's ancestry**, marking the ones that own a slot, + because where two trees disagree is rarely where the cause is. + +## Verifying a slice + +```sh +cd +cargo fmt --all --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace +``` + +49 tests pass on #18's head. `--workspace` matters: `rig-input` is a crate of +its own. + +Render checks are the last pass, not the iteration loop -- the owner asked for +that on 2026-09-14, since the layout tests cover the CPU part and the shots +cost real time: + +```sh +./scripts/run-headless.sh tabs --mode 1920x1200@60Hz --shot /tmp/out.png +./scripts/run-headless.sh tabs --replay /tmp/taps.touch --shot /tmp/out.png +./scripts/run-headless.sh tabs --mode 1920x1200@60Hz --resize 900x1200@60Hz --shot /tmp/rs.png +``` + +- The reference shots are `tabs`, `view`, `minimal` and `text` at 1920x1200, + plus `tabs` with a replay that switches to the image tab and adds two images. + A `.touch` line is ` down|move|up ` in the output's own pixels; the + tab strip is at y=24 and the five tabs at x = 192, 576, 960, 1344 and 1728, + with the image tab's add button near (1836, 1116). +- **A resize is its own case**, and `--resize` is it: the output changes under + the running app, and what it lands on must match a cold start at that size + byte for byte. That caught both of #16's defects and nothing in `cargo test` + can see it. +- **Run one at a time.** The rig reuses a single compositor and a single + output, so two invocations at once resize each other's window and quietly + screenshot the wrong thing. Two sets of shots were thrown away learning that. +- **Give a comparison worktree its own target dir.** While one was shared + between two checkouts I got results I could not reproduce afterwards; the + mechanism was never pinned down, so re-run any cross-checkout comparison in + isolation before believing it. + +Two drawing paths still have no shot of their own, and each needs a ui the +examples do not have, so both are throwaway examples written into the worktree +and deleted after: + +1. An image alone in a layer, which is the case that failed GPU validation when + every other test happened to have a rectangle in the same layer. +2. Six lines of 400px text, which forces the atlas to four pages and proves the + array grew and its group was rebuilt. + +`tabs` with the image replay covers rects, glyphs and images together, so that +one is an ordinary check now. ## How the work is sequenced @@ -223,11 +207,9 @@ was for. `/home/bob/repos/ai-app-2` is on `rustify`, worktree clean. fundamental changes first, such as library updates and core framework changes, so that code only has to be written once"*, and *"should probably start adding tests early on rather than later, so you don't have to make separate test -scripts and stuff. This might involve making the harness eventually, depends -on what needs tested."* - -So slices are ordered by how much depends on them, not by what is nearest -ready, and a slice arrives with tests rather than with a script in `/tmp`. +scripts and stuff."* So slices are ordered by how much depends on them, not by +what is nearest ready, and a slice arrives with tests rather than with a script +in `/tmp`. **Agree a design before sending another variation of it.** The owner stopped the fourth round of #11 with *"we should probably agree on the design here @@ -235,7 +217,7 @@ rather than you keep submitting variations that I review"*. When a review comes back about the shape of something rather than a defect in it, put the options and a recommendation in front of her and implement what she picks. -**Nothing is submitted without a separate review pass** — the installed +**Nothing is submitted without a separate review pass** -- the installed `pre-submit-review` skill: build clean, review the code, review the comments on their own once the code has settled, then verify the claim by running it. The fixes a review produces are themselves unreviewed code, so the passes repeat @@ -244,7 +226,7 @@ on #11 that format, clippy, tests and five headless renders had all passed, and on #12 a regression introduced by the review's own first draft. `audit.sh` in the skill directory prints every comment line a branch adds against a base ref; the owner's standing complaint is verbose agent comments, and the default -verdict is delete. **Machine-specific notes do not belong in the repository** — +verdict is delete. **Machine-specific notes do not belong in the repository** -- they live in `~/.claude/MACHINE.md` or a `this-machine-*` skill. Other standing instructions from the owner: @@ -254,73 +236,59 @@ Other standing instructions from the owner: runs one way from `app/` to Iris. - **Small, coherent PRs.** The original extraction PR was too large to review. A slice may be redone rather than transplanted, and need not remove every old - feature. Non-conflicting pull requests may be open at once — disjoint path - sets, each branched from current `upstream/main` rather than stacked. The - owner reviews small ones as they arrive and only avoids having two *large* - ones in flight, which is one more reason to keep a slice small. + feature. Non-conflicting pull requests may be open at once -- disjoint path + sets, each branched from current `upstream/main` rather than stacked. She + reviews small ones as they arrive and only avoids two *large* ones in flight. - **Order by dependency, largest reach first.** On 2026-09-13: *"do the large - reaching framework changes first so less has to be redone."* Pick the next - slice by how much sits on top of it, not by what is nearest ready, so each - piece of code is written once against the framework that will exist. + reaching framework changes first so less has to be redone."* - Do not recreate an `ai` branch in canonical Iris; the fork is the boundary. - **Never rewrite a pushed branch.** Follow review with additive commits, and merge `upstream/main` in rather than rebasing when a branch falls behind. - Respond to each review finding with a fix or a concise explanation. Do not add a ceremonial comment when the changed code already answers it. -- **A test has to guard something that could break again.** The owner deleted - #14's test as pointless: the rename it guarded cannot regress. When a fix is +- **A test has to guard something that could break again.** She deleted #14's + test as pointless: the rename it guarded cannot regress. When a fix is structural, the structure is the test. +- **Say who decided a constraint.** LAYOUT.md §2's "move slots carry + translation only" was written by an agent on 2026-09-04, was never asked for, + and read as settled until she said *"I was not aware that an agent decided + position slots should be translate only."* Mark an agent's own choice as one. -## The next slice +## What is left -**The plan above, as one slice on #18**: opt-in slots, the rewalk stretch -over stored locals, and `Span` saying `Scale` -- after the owner has answered -whether a slot carries a box, since that decides whether the locals are stored -beside window regions or *are* the regions. Then `set_child_offset` and -`LazySpan` to finish LAYOUT.md §2, then built-in alignment. +**Next, and small:** `LazySpan`, the last of LAYOUT.md §2. `set_child_offset` +is no longer part of it -- a child offset is just placing the child, which +`Painter::place` now does. -The archive is not a patch here: it writes `Widget::draw` against -`painter.set_size`, which #16 replaced with a returned `Size`, and it writes -lengths against `LayoutLen` and `density`, which canonical does not have. -Recreate on today's `Len` and let the dp slice follow. +Then, roughly in dependency order: -Still in the target, roughly in dependency order: +- **Built-in alignment, and probably size**, which the owner moved ahead of the + rest on 2026-09-14. Reproduced in the harness: `.width(rel(0.5))` inside a + `Dir::DOWN` span reports 200 of 400 and is handed the whole 400, and a `Pad` + in between does not change that. **Do not "fix" it by reading the child's + ortho `size_hint`** -- a `Pad` between the `SetSize` and the span has no hint + of its own, so the declared width silently goes back to filling. It works + only when nothing is in the way. Alignment has to belong to the widget rather + than be discovered through whatever happens to sit on top of it. -- **The rest of the position chain** (LAYOUT.md §2), on top of the slice - above: `set_child_offset` for a container that moves its children as a - group, and `LazySpan`. -- **Built-in alignment, and probably size**, after the chain rather than - before it: the owner reordered the two on 2026-09-14. Reproduced in the - harness -- `.width(rel(0.5))` inside a `Dir::DOWN` span reports 200 of 400 - and is handed the whole 400, and a `Pad` in between does not change that. **Do not "fix" that by reading the child's ortho `size_hint`**: a `Pad` - between the `SetSize` and the span has no hint of its own, so the declared - width silently goes back to filling. It works only when nothing is in the - way. Alignment has to belong to the widget rather than be discovered through - whatever happens to sit on top of it. - - Two things beyond the bug argue for it. Built-in size removes `SetSize`, - and with it the mismatch that made `OnResize`'s old default unsafe -- a - wrapper reporting one size while handing its child the whole box. And - built-in alignment is what would let `OnResize::Translate` apply to centred - content, which otherwise has to say `Redraw` because only its own draw knows - where the middle was. - - Size is the harder half: a declared size beside the one `draw` returns is two - sources of truth for one thing, so settle what each means before building it. -- **`OnResize::Translate`**, which still does nothing. `ActiveData::region` is both the box a widget was - given and the box its primitives occupy, and `mov` remaps out of it; keeping - a drawing at its old size while the box grows leaves the two disagreeing and - the next move stretches it. Found by rendering `tabs` against `main`, not by - a test. Whatever the chain does, the drawn box and the offered box have to - stop being one field. + Two things beyond the bug argue for it. Built-in size removes `SetSize`, and + with it a wrapper reporting one size while handing its child the whole box. + And built-in alignment is what would let `OnResize::Translate` apply to + centred content, which otherwise has to say `Redraw` because only its own + draw knows where the middle was. Size is the harder half: a declared size + beside the one `draw` returns is two sources of truth for one thing, so + settle what each means before building it. +- **`OnResize::Translate`, which still does nothing.** The chain removed half + its obstacle: a placed widget's slot holds the box it was offered while its + drawing is a set of fractions of that box, so the two are no longer one + field. What is still missing is a widget saying *where* in a bigger box its + unchanged drawing should sit, which is the alignment work above. - **`UiRenderState` behind `Rc>`**, queued by the owner on 2026-09-13 as fundamental, and especially so for text. - **`Len`, `LayoutLen` and dp.** The archive splits the type so that `rest` is unrepresentable where it is meaningless (a padding), and folds a density in - at resolve time. 21 files mention `Len`, so it is wide but shallow. After the - draw-size slice, not before: that one deletes the `desired_*` bodies this - would otherwise have to be threaded through. -- **The input restructure** — `src/default/sense.rs` becomes `src/rsc/sense.rs` + at resolve time. 21 files mention `Len`, so it is wide but shallow. +- **The input restructure** -- `src/default/sense.rs` becomes `src/rsc/sense.rs` (308 lines to 2313), plus `core/src/event/controller.rs`, `desktop/input.rs`, `android/input.rs` and `sense_tests.rs`: pointer capture, drag slop and axis, platform cancellation, mask-aware hit testing, event timestamps. The @@ -333,20 +301,20 @@ Still in the target, roughly in dependency order: - Shared resource-handle bookkeeping and replaceable glyph-atlas buckets. - Positioned text overflow and cluster-safe ellipsis. -Dependencies are current apart from `winit`, which stays on 0.30.12 until -0.31 leaves prerelease. `parley` 0.11.1 and `image` 0.25.10 are latest. +Dependencies are current apart from `winit`, which stays on 0.30.12 until 0.31 +leaves prerelease. `parley` 0.11.1 and `image` 0.25.10 are latest. + +The archive is a reference, not a patch to apply -- it writes `Widget::draw` +against `painter.set_size`, which #16 replaced with a returned `Size`, and +lengths against `LayoutLen` and `density`, which canonical does not have. +Recreate a change on today's types, leave app-specific behaviour out, and +verify it independently. ```sh cd /home/bob/repos/iris && git fetch upstream git diff --stat upstream/main..origin/archive/full-extraction ``` -The archive is a reference, not a patch to apply. Recreate a change on top of -canonical `main`, leave app-specific behaviour out, and verify it -independently. Pick disjoint path sets when two PRs are open, and branch each -from the latest `upstream/main` rather than stacking — unless the slice fixes -code another open branch replaces, in which case say so and stack deliberately. - ## How the renderer works now Current invariants, not history. Worth reading before touching `core/render`. @@ -362,29 +330,27 @@ Current invariants, not history. Worth reading before touching `core/render`. once per list; `ImageRender` owns the images and binds one per instance; the default owns nothing and draws every instance in one call. The renderer sets the pipeline, the shared group, the list's data and its vertex buffer, and - knows nothing else. Dispatch per list was measured at 6 instructions, 0.1% of - a frame at 256 and at 1024 layers, against the ~5,400 wgpu spends recording - one list; `tests/draw_cost.rs` is that measurement. + knows nothing else. - **The shared bind group is the window, the masks and the move chain**, given - to every draw. A mask texture would go here too. What a primitive samples is its own group, - and a primitive that samples nothing has no such group in its pipeline. + to every draw. A mask texture would go here too. What a primitive samples is + its own group, and a primitive that samples nothing has no such group in its + pipeline. - **Every binding size is stated.** A `None` minimum puts the binding on wgpu-core's late-sized list, which `is_ready` scans on every draw. - **`shader/prelude.wgsl` plus one file per primitive**, because one module cannot declare two types at the same binding. The prelude carries only what - every primitive uses — window, masks, vertex shader, `masked()` — and its - header is where binding numbers are written down; what a shader samples is - declared by that shader. + every primitive uses -- window, masks, the chain walk, the vertex shader, + `masked()` -- and its header is where binding numbers are written down. - **A texture handle is drawn like anything else.** `Painter::primitive` takes `impl PrimitiveLike`: a primitive, or something that yields one and does - whatever else drawing it needs — a `&TextureHandle` retains its share on the + whatever else drawing it needs -- a `&TextureHandle` retains its share on the way through, which a `Pod` primitive cannot. - **Order within a layer means nothing**, and the widgets do not rely on it: `Stack` gives each child its own layer and `TextEdit` draws its view in a child layer above the selection rectangles. - **Images are one texture and one bind group each**, so each drawn image is a - draw call. The owner chose that on 2026-09-13 over packing images into - arrays like atlas pages; a bindless `binding_array` was ruled out by Android + draw call. The owner chose that on 2026-09-13 over packing images into arrays + like atlas pages; a bindless `binding_array` was ruled out by Android support. Revisit only with her. - **Layers are never freed** (`TODO` in `primitive/layer.rs`), so every layer a session creates is walked every frame thereafter. Measured at ~2ns per empty @@ -420,59 +386,11 @@ git worktree add -b split/19-name /home/bob/repos/iris-pr19 upstream/main `/home/bob/repos/iris-pr18` is the live one. Every other `iris-pr*` worktree holds a merged branch; they are readable references, not places to build. -Every other `/home/bob/repos/iris-pr*` worktree holds a merged branch. They -are readable references; do not build new work on them. - -## Verifying a slice - -`iris` runs its own rig now (#17), so a rendering claim no longer has to be -driven from ai-app's submodule: - -```sh -cd -./scripts/run-headless.sh tabs --mode 1920x1200@60Hz --shot /tmp/out.png -./scripts/run-headless.sh tabs --replay /tmp/taps.touch --shot /tmp/out.png -``` - -The reference shots this session compared against are `tabs`, `view`, `minimal` -and `text` at 1920x1200, plus `tabs` with a replay that switches to the image -tab and adds two images. A `.touch` line is ` down|move|up ` in the -output's own pixels; the tab strip is at y=24 and the five tabs at x = 192, -576, 960, 1344 and 1728, with the image tab's add button near (1836, 1116). - -**A resize is its own case and the harness cannot see it.** Start an example, -change the output mode under it with `swaymsg output HEADLESS-1 mode WxH@60Hz`, -screenshot, and compare against a cold start at that size -- they must match -byte for byte. That is what caught both of #16's defects, and neither showed up -in 40 tests. - -```sh -cd -cargo fmt --all --check -cargo clippy --workspace --all-targets -- -D warnings -cargo test --workspace -``` - -42 tests pass on #18's head. `--workspace` matters: `rig-input` is a crate of -its own. - -Two drawing paths still have no shot of their own, and each needs a ui the -examples do not have, so both are throwaway examples written into the worktree -and deleted after: - -1. An image alone in a layer, which is the case that failed GPU validation - when every other test happened to have a rectangle in the same layer. -2. Six lines of 400px text, which forces the atlas to four pages and proves - the array grew and its group was rebuilt. - -`tabs` with the image replay covers rects, glyphs and images together, so that -one is an ordinary check now. - ## Cautions - Read `/home/bob/repos/ai-app-2/AGENTS.md` and the machine-wide rules first. - Anything about this machine — the GPU that comes and goes, measuring a small - performance difference, the emulator — is in `~/.claude/MACHINE.md` and the + Anything about this machine -- the GPU that comes and goes, measuring a small + performance difference, the emulator -- is in `~/.claude/MACHINE.md` and the `this-machine-*` skills, and belongs there rather than here. - Keep Iris generic: session drivers, transcripts, setup and server concepts, app icons and product fonts stay in ai-app. Android and desktop code is Iris