Files
ai-app/docs/IRIS_EXTRACTION_HANDOFF.md
T

17 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 43ce8c7 (#12, pointer routing). Fourteen slices are in.

  • #16 split/16-draw-size, worktree /home/bob/repos/iris-pr16, head 9520996. A widget sizes itself while drawing; SizeCtx, the desired_* methods and the size cache are gone, and on_resize(axis) drives the retained path. One review round answered: Widget::draw returns the Size (no Painter::set_size), both place methods are folded back into widget_within/draw_inner, Painter::size_hint records the size dependency place used to, and () sizes itself rest so it is a gap. tabs (all five tabs, and a replay that adds two images), view and minimal render byte-identical to upstream/main.
  • #17 split/17-headless-rig, worktree /home/bob/repos/iris-pr17, head deb9c1b. The compositor script, rig-input's replay-touch and the .touch parser, so a rendering claim no longer has to be checked from ai-app's checkout. One review round answered: machine-specific prose out of the comments, and sway/swaymsg/grim detected up front. Small, and disjoint from #16.

Waiting on the owner: whether size dependencies should be recorded per axis. They could be, but highest_reader's only trigger is needs_redraw, which carries no axis, so the precision would be unusable until the trigger changes: redraw a child first, compare its new size to the old, and propagate only to readers of the axes that moved. Offered on #16 as its own slice, ahead of the position chain.

Check for a review before starting anything, and read the newest submitted_at rather than the first result:

TOKEN=$(cat ~/.config/gitea/token)
N=12
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/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.
  • 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 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 structural, the structure is the test.

The next slice

Built-in alignment — see the first entry below. #16 and #17 are both waiting on review, and the alignment slice is disjoint from either.

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:

  • Built-in alignment, and probably size, directly after #16 and before the position chain: the chain is an optimisation, this is behaviour. A child of a span is handed the full extent on the ortho axis, so .width(rel(0.5)) inside a Dir::DOWN span changes what the child reports and not the box it gets. 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.

  • The position chain (LAYOUT.md §2): UiData::move_offsets, per-primitive slot ids and a bounded chain walk in WGSL, so moving a subtree writes one slot instead of every descendant's primitives.

    It also has to make OnResize::Translate possible, which is the reason that variant does nothing today. 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.

  • 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 restructuresrc/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 archive's own consumes is what #12 landed, so that part transplants; tests/pointer_routing.rs is 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 PrimitiveRegistry keys ids by TypeId, so the kind comes from the type and there are no RECT/GLYPH/TEXTURE constants. 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::render makes a PrimitiveRender that states the layout its shader reads, uploads whatever it owns, and records its draws. GlyphRender owns the atlas and binds it 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.
  • The shared bind group is the window and the masks, 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 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.
  • 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 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 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 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, branch rustify.
  • iris/ is a submodule pinned at 32f6ad8, the complete extracted snapshot, and .gitmodules points 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 main and origin/archive/full-extraction both name 32f6ad8, the target snapshot. history/full names the source-history result a615bcd.
  • 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/main in its own worktree:
cd /home/bob/repos/iris && git fetch upstream
git worktree add -b split/15-name /home/bob/repos/iris-pr15 upstream/main

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

cd <iris-worktree>
cargo fmt --all --check
cargo clippy --all-targets -- -D warnings
cargo test

--workspace when the slice crosses workspace crates. A rendering claim needs a real run, which until the rig is extracted means driving it from ai-app's submodule:

cd /home/bob/repos/ai-app-2/iris
./scripts/run-headless.sh tabs --dir /home/bob/repos/iris-prNN \
    --shot /tmp/out.png --seconds 3
./scripts/run-headless.sh tabs --dir ... --replay /tmp/taps.touch --shot ...

A .touch file is <ms> down|move|up <x> <y> in the output's own pixels. The tabs example's five tabs sit at x = 192, 576, 960, 1344 and 1728 on a 1920x1200 output; tapping the third and then the bottom-right button twice adds two images.

Three renders cover the drawing paths, and each needs a different ui, so two of them are throwaway examples written into the worktree and deleted after:

  1. tabs with that replay — rects, glyphs and images together.
  2. 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.
  3. Six lines of 400px text, which forces the atlas to four pages and proves the array grew and its group was rebuilt.

Those three become ordinary tests with the harness slice, which is the argument for doing it next.

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 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 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 main ai-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 the iris-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)

URLs are https://git.arirex.me/iris/iris/pulls/{number}.