Layout follow-ups drain: anchor + sticky + will-change + transform (L1–L8)#69
Merged
Conversation
Triage workflow (6 cluster analyzers -> synthesis -> skeptical review) classified every open follow-up in follow-ups.md. 12 actionable slices across layout (sequential, shared systems.rs), render (sequential, shared PackedInstance contract), and text-editing tracks. Speculative/unused items (position_try_max_depth cap, multi-ref reftest aggregation, golden-prune) deferred with documented re-open triggers; superseded items doc-flipped; subsystem/renderer-blocked items confirmed blocked. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DHcf8nQ9PTT3m5E7u3Q6XV
Behavior-preserving extraction of the ~265-line anchor_resolution into three private free helpers, leaving the system a thin driver: - build_anchor_edge_map (steps 2+3: resolve_target + entity_epochs closures, edge insert + TargetMissing warn, kahn_anchor_sort, InCycle/dropped_targets) - apply_anchor_broken_markers (step 6: the three idempotent LayoutAnchorBroken marker loops, order + cleanup guard verbatim) - emit_anchor_warns (step 7: dedupe gate + exhaustive AnchorErrorKind warn match) The step-5 topological walk stays inline (most entangled body; out of the follow-up's named scope). dropped/dropped_targets keep HashSet semantics (mirrors kahn_anchor_sort's return). Zero observable change — the existing anchor suite is the red/green signal; no new tests. Gives later anchor slices clean helpers to rebase onto. First slice of the follow-ups-drain campaign (docs/plans/2026-06-18-followups-drain.md). follow-ups.md entry flipped to LANDED. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DHcf8nQ9PTT3m5E7u3Q6XV
Resolve CSS anchor-size(<axis>) inside PositionTry insets against the per-try anchor box. try_anchored_position already has the override-aware anchor_size: Vec2 in scope, so AnchorSize(Width)->x / (Height)->y short- circuits before delegating to length_inset_to_px — honored independent of which edge the inset sits on (cross-axis covered by test). The closed Length enum forces an arm at every match site; non-anchor contexts (track sizing, taffy translation, transforms) degrade AnchorSize to 0/auto exactly like Cq* outside its query. Sticky has no anchor box: resolve_sticky_inset resolves AnchorSize to 0.0 + a StickyAnchorSizeUnsupported warn-once. Deletes the now-unreachable AnchorErrorKind::AnchorSizeUsed forward-compat hook (the warn is semantically wrong now that the term actually resolves; no Reflect consumer enumerated it). AxisDimension registered for reflection. Reverses the spec 3.4 'tier-C deferred to v1.x' decision (user-authorized via the follow-ups drain): updates display-and-positioning.md 3.4 + README 5. follow-ups.md flipped to LANDED. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DHcf8nQ9PTT3m5E7u3Q6XV
All 11 existing anchor tests use AnchorRef::Name; the direct AnchorRef::Entity(e) branch had zero end-to-end coverage. Adds two headless tests (no production change — the Entity arm + Display::None guard already exist in build_anchor_edge_map's resolve_target closure): - anchor_entity_ref_positions_relative_to_target: plain Node target (no anchor_name/registry) addressed by Entity, anchored 10px below → override + ResolvedLayout y == 60, no LayoutAnchorBroken. - anchor_entity_ref_display_none_target_is_missing: Entity ref at a Display::None target → zero override + LayoutAnchorBroken + (anchored, TargetMissing) warn, exercising the guard via the Entity arm rather than the Name arm. Display::None (not despawn) chosen for the negative: a despawned Entity makes resolve_target return Some(e) (guard does not fire) and hits a different miss path — Display::None is the clean deterministic guard. follow-ups.md flipped to LANDED. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DHcf8nQ9PTT3m5E7u3Q6XV
Sticky Cqw/Cqh/Cqi/Cqb/Cqmin/Cqmax insets now resolve against the sticky entity's own nearest container-query ancestor instead of degrading to 0.0 + StickyCqDeferred warn. resolve_sticky_inset delegates Cq* to the existing translate::resolve_cq_unit_px (cqw/cqh/cqi/cqb/cqmin/cqmax math + writing-mode inline/block axis swap + no-ancestor viewport fallback). Timing fix (plan-review major): the CQ-ancestor size is read CURRENT-frame from Taffy (tree.by_entity + tree.tree.layout), NOT from last-frame ResolvedLayout — sticky_offset runs in PostTaffyOverrides before WriteResolvedLayout, and every other size it consumes (self/parent/scroll) is current-frame Taffy, so a last-frame container size would be internally inconsistent on the establishing/resize frame. sticky_offset builds a current-frame container_index and resolves the CQ frame once per entity via nearest_container_with_size; this also collapses the fixtures to one update(). StickyCqDeferred warn retired (resolve_cq_unit_px owns the no-ancestor warn — delegating makes sticky consistent with every other Cq* site). The L2 Length::AnchorSize sticky arm is preserved. Tests: layout_sticky 18/18 (4 new incl. Cqi/Cqb VerticalRl axis-swap + separate-CQ-ancestor-vs-scroll-container). Spec §2.3/§3.4 + container-queries §1.4 + README §5 updated; follow-ups.md LANDED. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DHcf8nQ9PTT3m5E7u3Q6XV
A sticky element with BOTH inset_top and inset_bottom set now honors both edges (CSS positioned-layout §6.3 dual-clamp) instead of the v1 'top wins' deviation. compute_sticky_displacement applies both thresholds per axis: pin to the top line (max U) when scroll pushes the natural position above it, pin to the bottom line (min L) when below it, natural in between. The U>L degenerate case (box taller than the sticky band) re-applies top-precedence after the bottom clamp, so the box never escapes upward. Band-clamp keeps the explicit .max(lo).min(hi) ordering (panic-free when box taller than parent). Single-edge cases unchanged (verified at runtime, not just by reasoning). Flipped both regression tests: the pure unit sticky_both_top_and_bottom_* (top_wins -> dual_clamp_bottom_honored) and the integration sticky_both_top_and_bottom_inset_top_wins -> _bottom_honored_near_scroll_end; added a dedicated U>L conflict test (anti-vacuous: deleting the top-precedence branch fails it) and a both-extremes integration fixture. Tests: layout_sticky 19/19, lib layout 226/226. Supersedes Phase 7 D4 in CHANGELOG; spec §2.3 documents dual-clamp; follow-ups.md LANDED. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DHcf8nQ9PTT3m5E7u3Q6XV
A sticky child B of a sticky-displaced ancestor A previously computed its thresholds from A's NATURAL Taffy position, ignoring A's displacement. Fix, both inside sub-pass 6a (sticky_offset): - depth-sort the sticky entities by ChildOf-chain depth ascending, so an outer (shallower) sticky resolves and inserts its PostTaffyPositionOverrides entry BEFORE an inner (deeper) one reads it — same-frame eventual consistency via topological-by-depth ordering, not two-frame. - world_position (only called from sticky_offset) consults the just-written overrides map per ancestor segment: the stored override is already natural_rel + displacement (the correct displaced rel-to-parent), so the inner sticky's threshold geometry sees the displaced outer position. The inner override value is correct by construction (the consult only feeds threshold COMPUTATION, never the e_natural_rel written to the map — no double-counting; pinned by a dedicated test). per-entity memo reset keeps the override-aware walk from returning pre-override values. L4/L5 surfaces untouched. sort_by_cached_key avoids re-walking the chain per comparison. Tests: layout_sticky 21/21 (2 new: inner-tracks-displaced-outer + no-double-count), lib layout 226/226. Spec §2.3 nested-sticky limitation removed; follow-ups.md LANDED. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DHcf8nQ9PTT3m5E7u3Q6XV
forms_stacking_context now forms an SC when Containment.will_change names an SC-forming property (CSS will-change trigger 5: a property named in will-change forms an SC iff it would at a non-initial value). New WillChangeProperty::forms_stacking_context() names the SC-forming subset once: Transform / Opacity / Filter form one; ZIndex (needs positioning) and ScrollPosition do not. Trigger-5b clause reads the already-present containment param — the 6f sub-pass closure already passes containment_q.get(e).ok(), so the predicate change lights up end-to-end with no signature/call-site change. SC-trigger half only — the render layer-promotion hint stays deferred (no composition-layer/RenderLayers concept exists in render/). Both will-change follow-up entries flip the SC-former half to LANDED; the Phase-8 entry keeps layer-promotion open. Tests: lib layout 229 (+3 unit: subset + predicate-forms + layout-only-doesnt), layout_stacking 16 (+2 e2e: will-change Transform forms SC, ZIndex does not). Spec stacking-and-top-layer §2 trigger 5 + §7 + transforms-and-containment §5.3. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DHcf8nQ9PTT3m5E7u3Q6XV
translateX(p%) / translateY(p%) now resolve against the entity's OWN current-frame border box (x% of width, y% of height) per CSS Transforms, instead of contributing 0.0. The single 0.0 gap was length_px, the resolver for both the Translate longhand (T factor of M=T·R·S·M_transform) and TransformMatrix::Translate (M_transform); replaced with axis-aware translate_length_px(l, axis_px): Px=>p, Percent=>p*0.01*axis_px, else 0.0. Box-size source is the current-frame Taffy tree (tree.by_entity -> tree.tree.layout), NOT ResolvedLayout — sub-pass 6e transform_composition runs in PostTaffyOverrides BEFORE WriteResolvedLayout, so ResolvedLayout.size is last-frame there; mirrors anchor_resolution's same-frame Taffy read. compose_transform / transform_matrix_to_mat4 gained a box_size param (pure fns; the buiy_verify Tier-3 transform_roundtrips invariant + unit tests pass Vec2::ZERO since Px translates are box-independent). Cq*/Fr translate stays a documented 0.0 residual (needs the nearest CQ-ancestor frame, like sticky L4) behind a warn-once; z-percent is invalid -> 0.0. Tests: layout_transforms +4 (percent x against width, percent y against height, mixed percent+px, Cq* residual), Px-only unchanged. Spec transforms-and- containment §1/§1.1; follow-ups.md PERCENT LANDED (Cq* residual noted). Final layout-track slice. Whole track (L1-L8) green under the full workspace gate (fmt + clippy --workspace + doc --workspace + cargo test --workspace 175/0). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DHcf8nQ9PTT3m5E7u3Q6XV
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
First track of the follow-ups-drain campaign (
docs/plans/2026-06-18-followups-drain.md): drains the actionable layout follow-ups fromdocs/plans/follow-ups.md. All 8 slices land on the single sharedlayout/systems.rs, so they were implemented strictly sequentially, each plan-reviewed and impl-reviewed by fresh agents and gated independently.Slices
7cf2463refactor anchor_resolution — extractbuild_anchor_edge_map/apply_anchor_broken_markers/emit_anchor_warns; behavior-preserving base for the anchor edits.a55e1fbanchor-size() term —Length::AnchorSize(AxisDimension)resolves against the per-try anchor box; deletes the deadAnchorErrorKind::AnchorSizeUsed. Reverses the §3.4 v1.x deferral (campaign-authorized).657bc20AnchorRef::Entity coverage — direct-reference positive + Display::None-target negative (the branch had zero end-to-end coverage).465eec9sticky Cq insets* — resolve against the sticky entity's own nearest CQ ancestor viaresolve_cq_unit_px; CQ size read current-frame from Taffy (not stale ResolvedLayout); retiresStickyCqDeferred.782728esticky dual-clamp — bothinset_top+inset_bottomhonored (CSS §6.3), with top-precedence for the box-taller-than-band case; flips thetop_winsregression test.434e9afsticky-inside-sticky — depth-sort thesticky_offsetloop +world_positionconsults the just-written overrides so a nested sticky tracks its displaced ancestor (same-frame, no double-count).90348f1will-change SC former —forms_stacking_contextforms an SC whenwill_changenames an SC-forming property (Transform/Opacity/Filter; not ZIndex/ScrollPosition). SC-trigger half only; render layer-promotion stays deferred.9681bafpercent translate —translateX/Y(%)resolve against the entity's own current-frame border box; Cq*/Fr translate stays a documented residual.Each slice updates its spec touchpoint and flips its
follow-ups.mdentry to LANDED.Verification
Full workspace gate green on the accumulated track:
cargo fmt --all --check,cargo clippy --workspace --all-targets -D warnings,RUSTDOCFLAGS=-D warnings cargo doc --workspace --no-deps, andcargo test --workspace(175 test binaries ok, 0 failed). No GPU/#[ignore]work in this track (all changes are headless layout logic).🤖 Generated with Claude Code