Rewrite the handoff around what is true now: the chain's invariants, the open pixel decision, and what is left

This commit is contained in:
iris-ai committed 2026-09-14 14:07:11 -04:00
1 parent d5486b124a
commit ba275fc966
1 file changed
+237 -319
+237 -319
View File
@@ -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 <iris-worktree>
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 `<ms> down|move|up <x> <y>` 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<RefCell<..>>`**, 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 <iris-worktree>
./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 `<ms> down|move|up <x> <y>` 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 <iris-worktree>
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