diff --git a/docs/LAYOUT.md b/docs/LAYOUT.md index f13af43..5c71a77 100644 --- a/docs/LAYOUT.md +++ b/docs/LAYOUT.md @@ -947,3 +947,72 @@ When this lands, copy this entry into `IRIS.md` (newest first): > `SizeCtx` and `Cache` are gone with it — see `LAYOUT.md` for the full > design, the move-offset mechanism this shipped alongside, and the file > list. + +## Masks with a shape (decided 2026-09-07, not yet built) + +Iris, on the code block's scrolling: "the code block scrolling currently +masks in an inner rectangle. Ideally masks should have a shape +associated with them, rounded rectangle being one of them, and/or +another widget you can select, so that the mask becomes the parent +container with rounded edges. Make sure alpha works properly with it, +eg. on the corners where alpha should be decreased / multiplied." + +**What exists.** `Mask` in `shader.wgsl`/`data.rs` is two `UiSpan`s and +a `move_idx`; `fs_main` resolves it and does `color *= 0.0` outside the +rectangle -- a hard cut on a pixel boundary. `Masked` (`widget/mask.rs`) +sets the painter's mask to its own region. Separately, `draw_rounded_rect` +already produces an anti-aliased rounded edge from +`distance_from_rect(pos, center, corner, radius)` with a half-pixel +`smoothstep`, and the border variant multiplies a second coverage in. + +**Design.** + +1. **A mask is a shape, and the shape is the same SDF the `Rect` + primitive draws with.** `Mask` gains `radius: f32` (one uniform + corner radius, matching `Rect.radius`; per-corner radii only when a + concrete need appears). The fragment stage computes coverage as + `1.0 - smoothstep(-min(edge, radius), edge, distance_from_rect(...))` + -- the *same expression* `draw_rounded_rect` uses, factored into one + function both call -- and does `color.a *= coverage`. So a mask whose + region and radius equal a rounded container's are clipped to exactly + the pixels that container fills, corner alpha included, because they + are the same arithmetic. `radius = 0` becomes a half-pixel + anti-aliased edge instead of today's hard cut, which is what `Rect` + does already, so a masked rect and an unmasked one look the same. +2. **Nested masks chain and multiply, like moves.** Today one + `mask_idx` per primitive; nesting two rectangles could be handled by + intersecting spans on the CPU, but the intersection of two rounded + rectangles is not a rounded rectangle. So `Mask` gains `parent: u32` + (the enclosing mask's slot, or the sentinel), the shader walks the + chain multiplying coverage, and the walk is bounded the way + `resolve_move` is (`MOVE_CHAIN_LIMIT`'s sibling; assert on overflow + in debug, print the chain). The painter's `set_mask` records the + current mask as the parent. Alpha multiplies rather than takes a + minimum, so a pixel in two feathered corners is dimmed by both -- + that is what "alpha should be multiplied" asks for and what a real + compositor does. +3. **The widget API: the container is the mask.** `Masked` takes a + `MaskShape` (`Rect`, `Rounded(radius)`); and the rounded `Rect` + widget, the thing a code block or card is already inside, gets a + `.masked()` builder that wraps its children in a `Masked` carrying + *its own* radius. One value, by construction, never a radius on the + container and a second one on the mask to keep in sync. The code + block in `transcript-ui/src/row.rs` (`.masked()` at the inner + rectangle) moves to masking at the rounded container instead. +4. **Hit-testing keeps the rectangle.** Input outside the rounded + corner but inside the box is a few pixels; not worth a second SDF + walk on the CPU. State this in the `Masked` doc so nobody "fixes" it. + +**Rejected.** A stencil buffer (a second pass per mask level and no +anti-aliasing); the scissor rectangle (rectangles only, no alpha); +rendering a masked subtree to an offscreen texture and compositing +(a texture allocation per mask, every frame it scrolls, on the phone). + +**Pass conditions.** A headless test draws a rounded container with a +masked child that overhangs all four sides and asserts the child's +coverage at a corner pixel equals the container's own coverage there +(same SDF, so exactly equal, not approximately); a nested-mask test +asserts the product at a pixel inside both feathers; a +`run-headless.sh --phone` screenshot of a scrolled code block shows +rounded corners with no square pixels poking out at the top and bottom +of the scrolled content. Record the commands in RUST.md when it lands. diff --git a/docs/RUST.md b/docs/RUST.md index 1ff8281..8c09760 100644 --- a/docs/RUST.md +++ b/docs/RUST.md @@ -43,6 +43,25 @@ gated on her verdict**, so this pass works the P0 defects and the pure prerequisites in this order. Each item is ticked here by the agent that closes it. +### Queue, 2026-09-07 (orchestrator) + +In order; two builders at a time. Each is ticked here by the agent that +closes it. + +- [ ] Test rig, layers 1 and 2 ("Three test layers" below). Running. +- [ ] Fling parity with Compose, and the phone's keyboard push-up, with + insets shown in the diagnostics overlay. Running, in a worktree. +- [ ] Phone logging through Dev Updater (Iris has no logcat; see + docs/TODO.md and the memory note): research how Dev Updater shows an + app's runtime log, design the smallest route (the app keeps its own + recent log; a debug button copies it; Dev Updater reads it), write + the decision in docs/DECISIONS.md, build it. +- [ ] Masks with a shape -- docs/LAYOUT.md "Masks with a shape (decided + 2026-09-07)". Rounded masks through the `Rect` SDF, chained and + multiplied, the container as the mask. +- [ ] Compose app: the `Reversed range` crash in `ToolInput.highlighted` + (docs/TODO.md). Main branch, not rustify. + ### Desktop and phone share the code (Iris, 2026-09-07) Iris plans to develop a desktop app as well, and asked that most code be