21 KiB
Iris extraction handoff
Operational handoff for pulling Iris out of ai-app into a standalone framework. 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.
#18 split/18-position-chain is open, worktree /home/bob/repos/iris-pr18,
head db1751f, five commits. LAYOUT.md §2's O(1) subtree movement, plus the
Remap retirement the owner asked for on top of it.
- Every active widget owns a slot in
UiRenderState::moves-- a translation in physical pixels and the slot it is relative to. A primitive instance and a mask each name one, andprelude.wgslwalks the chain and adds the accumulated delta. A mask resolves its own chain rather than the drawn primitive's, so a stationary viewport can clip content moving inside it. try_reuse's translation case writes one slot instead of remapping a subtree: 100 primitive region writes to 0 on a span of 20 rows five primitives deep.window_regionwalks the same chain on the CPU, so hit testing and anyone asking in window pixels agree with the shader.Moves::resolvestops atCHAIN_LIMITas the shader does, and debug-asserts that it reached the end.Vec2isrepr(align(8)), WGSL's alignment for avec2<f32>, so a GPU struct holding one is laid out the way its shader reads it without saying so itself.GlyphPrimitiveno longer states its own alignment; both it andMoveOffsetkeep a manualunsafe impl Pod, because the trailing padding that alignment introduces is whatderive(Pod)refuses. No manual padding fields -- the owner rejected one on 2026-09-14.Remapis gone, withUiScalar::outside,UiSpan::outsideandLerpUtil::lerp_inv. A translation never needed an inversion: shifting a box shifts everything composed into it by the same amount, sincelerp(s + d, e + d, t) == lerp(s, e, t) + don both channels, whatever the box's relative extent. Only a change of length needs each part's fraction recovered, which isUiRegion::stretchbehindUiRegion::stretchable. The decision is made once before the walk and neither relocation method branches, which is how the owner asked for it.
Two invariants everything here rests on. resolved = region + resolve(slot), so anything that rewrites a region owes that slot a zero --
stretch does it for the subtree it rewrites, draw_inner for the widget it
draws. And a stretch is only expressible out of a box with a relative extent;
a fixed length holds its parts as offsets from its start and keeps no fraction.
The open question on #18, and the numbers for it
The owner proposed, and I agree with, opt-in chaining that Scroll would
choose plus recalculating rather than repositioning when a region cannot
be moved. Neither is implemented. What is settled and what is not:
- Recalculating rather than repositioning is in, as the
Remapretirement above. It costs the per-axis carry: a box that changed length on one axis and not the other is now redrawn rather than remapped. Six oftabs's fourteen relocations and five oftext's sixteen, and one extra redraw per frame onreplace_cost-- 354,310,889 instructions against 354,272,387, noise. - Opt-in chaining is not, and the design question is who may opt in. A
widget can only move its subtree if its descendants chain through it, so
opting in has to be done by whoever performs the move. My recommendation on
the PR: any container that re-places a child after drawing it (
Span,Aligned,Scrollall do), not onlyScroll. That keeps the chain 2-4 deep instead of full tree depth, and keeps the slot write for ordinary re-placement. - Giving every widget a slot, which is what #18 does and what I read §2 to say, is the thing to change: it puts a primitive's walk at full tree depth for no benefit, since almost every slot is zero.
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 would measure the same. |
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. |
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:
TOKEN=$(cat ~/.config/gitea/token)
N=18
curl -s -H "Authorization: token $TOKEN" \
https://git.arirex.me/api/v1/repos/iris/iris/pulls/$N/reviews
curl -s -H "Authorization: token $TOKEN" \
https://git.arirex.me/api/v1/repos/iris/iris/pulls/$N/reviews/<id>/comments
curl -s -H "Authorization: token $TOKEN" \
https://git.arirex.me/api/v1/repos/iris/iris/issues/$N/comments
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.
How the work is sequenced
Most fundamental first, from the owner on 2026-09-13: "please do more 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.
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 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
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
until a round finds nothing. It has earned its place repeatedly: four defects
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 —
they live in ~/.claude/MACHINE.md or a this-machine-* skill.
Other standing instructions from the owner:
- Pull Iris out even if the Rust application switchover is not accepted. No
app, session, transcript, setup or server concepts in Iris; the dependency
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/mainrather 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. - 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.
- Do not recreate an
aibranch in canonical Iris; the fork is the boundary. - Never rewrite a pushed branch. Follow review with additive commits, and
merge
upstream/mainin 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 structural, the structure is the test.
The next slice
Nothing, until the owner answers the opt-in question on #18 -- the shape of
the slot set decides what set_child_offset even is, so building it first
risks writing it twice. After that: set_child_offset and LazySpan to finish
LAYOUT.md §2, then built-in alignment.
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.
Still in the target, roughly in dependency order:
-
The rest of the position chain (LAYOUT.md §2), on top of #18:
set_child_offsetfor a container that moves its children as a group, andLazySpan. -
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 aDir::DOWNspan reports 200 of 400 and is handed the whole 400, and aPadin between does not change that. Do not "fix" that by reading the child's orthosize_hint: aPadbetween theSetSizeand 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 madeOnResize's old default unsafe -- a wrapper reporting one size while handing its child the whole box. And built-in alignment is what would letOnResize::Translateapply to centred content, which otherwise has to sayRedrawbecause only its own draw knows where the middle was.Size is the harder half: a declared size beside the one
drawreturns is two sources of truth for one thing, so settle what each means before building it. -
OnResize::Translate, which still does nothing.ActiveData::regionis both the box a widget was given and the box its primitives occupy, andmovremaps 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 renderingtabsagainstmain, not by a test. Whatever the chain does, the drawn box and the offered box have to stop being one field. -
UiRenderStatebehindRc<RefCell<..>>, queued by the owner on 2026-09-13 as fundamental, and especially so for text. -
Len,LayoutLenand dp. The archive splits the type so thatrestis unrepresentable where it is meaningless (a padding), and folds a density in at resolve time. 21 files mentionLen, so it is wide but shallow. After the draw-size slice, not before: that one deletes thedesired_*bodies this would otherwise have to be threaded through. -
The input restructure —
src/default/sense.rsbecomessrc/rsc/sense.rs(308 lines to 2313), pluscore/src/event/controller.rs,desktop/input.rs,android/input.rsandsense_tests.rs: pointer capture, drag slop and axis, platform cancellation, mask-aware hit testing, event timestamps. The archive's ownconsumesis what #12 landed, so that part transplants;tests/pointer_routing.rsis the acceptance criterion. -
Retained span, scrolling and layout placement.
-
Retained paints, selection, overlays and shared UI runtime state.
-
Generic desktop/Android framework hosts and reusable example/APK tooling.
-
Application-owned fonts and application-named font families.
-
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.
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.
- A primitive registers itself by being drawn. The type carries its own
WGSL, and
PrimitiveRegistrykeys ids byTypeId, so the kind comes from the type and there are noRECT/GLYPH/TEXTUREconstants. Nothing is seeded, so an id depends on what a ui drew first and a ui pays only for the pipelines it uses. - Each primitive records its own draws.
Primitive::rendermakes aPrimitiveRenderthat states the layout its shader reads, uploads whatever it owns, and records its draws.GlyphRenderowns the atlas and binds it once per list;ImageRenderowns 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.rsis that measurement. - 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.
- Every binding size is stated. A
Noneminimum puts the binding on wgpu-core's late-sized list, whichis_readyscans on every draw. shader/prelude.wgslplus 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.- A texture handle is drawn like anything else.
Painter::primitivetakesimpl PrimitiveLike: a primitive, or something that yields one and does whatever else drawing it needs — a&TextureHandleretains its share on the way through, which aPodprimitive cannot. - Order within a layer means nothing, and the widgets do not rely on it:
Stackgives each child its own layer andTextEditdraws 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_arraywas ruled out by Android support. Revisit only with her. - Layers are never freed (
TODOinprimitive/layer.rs), so every layer a session creates is walked every frame thereafter. Measured at ~2ns per empty layer per frame, which is why it is the TODO's problem and not a bug of its own.
Repository topology
ai-app checkout
/home/bob/repos/ai-app-2,origin = git@git.arirex.me:iris/ai-app.git, branchrustify.iris/is a submodule pinned at32f6ad8, the complete extracted snapshot, and.gitmodulespoints at the bot fork, not canonical Iris.- Do not change either casually: ai-app needs the complete snapshot while canonical Iris is only partly caught up. Reconcile when canonical contains what ai-app needs, or when the owner accepts a temporarily non-building pin.
standalone Iris checkout
/home/bob/repos/iris,origin= fork,upstream= canonical.- Fork
mainandorigin/archive/full-extractionboth name32f6ad8, the target snapshot.history/fullnames the source-history resulta615bcd. - Do not reset, overwrite or force-push fork
main: it is both the target reference and the commit ai-app pins. - Start each new branch from current
upstream/mainin its own worktree:
cd /home/bob/repos/iris && git fetch upstream
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:
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.
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:
- 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.
- 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.mdand 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.mdand thethis-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 work only when it is a generic host or platform integration.
- Preserve the dirty-worktree rule. All worktrees were clean at handoff; anything found later may be the owner's or another agent's.
- Do not delete the archived snapshot or the fork
mainai-app pins. - A complete target branch is not permission to recreate the giant PR.
- Another agent was freeing disk on this VM and removed
target/from theiris-pr*worktrees once. Sources and git state were untouched. Tell peers before changing shared machine tooling, and expect a cold rebuild sometimes.
Merged so far
| PR | On canonical main |
|---|---|
| #2 | Build on the current nightly (4275314) |
| #3 | Request a frame after resize (936fbdd) |
| #4 | Decouple iris-core from winit (465e430) |
| #5 | Use vsync by default (ec2b5d4) |
| #6 | Notify winit before presenting (db9b0f2) |
| #7 | Keep unsafe reference helpers internal (0191f20) |
| #8 | Initialize the window uniform from the surface (6e271e8) |
| #9 | Preserve primitive-count recursion (b90c855) |
| #10 | Text layout and rendering on Parley (0f6a28b) |
| #11 | Atlas as an array texture, and the primitive rendering overhaul (b234497) |
| #13 | Build on wgpu 30 (00d2230) |
| #14 | Rename the Sized widget to SetSize (32b1038) |
| #15 | Run a ui without a window, and test one (c8ac669) |
| #12 | Route pointer input per kind (43ce8c7) |
| #16 | Size a widget while drawing it, not in a pass of its own (f942385) |
| #17 | Bring the headless rig into the repository (ca2b4b2) |
URLs are https://git.arirex.me/iris/iris/pulls/{number}.