Skip to content

Layout follow-ups drain: anchor + sticky + will-change + transform (L1–L8)#69

Merged
intendednull merged 9 commits into
mainfrom
followups-drain
Jun 18, 2026
Merged

Layout follow-ups drain: anchor + sticky + will-change + transform (L1–L8)#69
intendednull merged 9 commits into
mainfrom
followups-drain

Conversation

@intendednull

Copy link
Copy Markdown
Owner

First track of the follow-ups-drain campaign (docs/plans/2026-06-18-followups-drain.md): drains the actionable layout follow-ups from docs/plans/follow-ups.md. All 8 slices land on the single shared layout/systems.rs, so they were implemented strictly sequentially, each plan-reviewed and impl-reviewed by fresh agents and gated independently.

Slices

  1. 7cf2463 refactor anchor_resolution — extract build_anchor_edge_map / apply_anchor_broken_markers / emit_anchor_warns; behavior-preserving base for the anchor edits.
  2. a55e1fb anchor-size() termLength::AnchorSize(AxisDimension) resolves against the per-try anchor box; deletes the dead AnchorErrorKind::AnchorSizeUsed. Reverses the §3.4 v1.x deferral (campaign-authorized).
  3. 657bc20 AnchorRef::Entity coverage — direct-reference positive + Display::None-target negative (the branch had zero end-to-end coverage).
  4. 465eec9 sticky Cq insets* — resolve against the sticky entity's own nearest CQ ancestor via resolve_cq_unit_px; CQ size read current-frame from Taffy (not stale ResolvedLayout); retires StickyCqDeferred.
  5. 782728e sticky dual-clamp — both inset_top + inset_bottom honored (CSS §6.3), with top-precedence for the box-taller-than-band case; flips the top_wins regression test.
  6. 434e9af sticky-inside-sticky — depth-sort the sticky_offset loop + world_position consults the just-written overrides so a nested sticky tracks its displaced ancestor (same-frame, no double-count).
  7. 90348f1 will-change SC formerforms_stacking_context forms an SC when will_change names an SC-forming property (Transform/Opacity/Filter; not ZIndex/ScrollPosition). SC-trigger half only; render layer-promotion stays deferred.
  8. 9681baf percent translatetranslateX/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.md entry 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, and cargo 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

intendednull and others added 9 commits June 18, 2026 05:06
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
@intendednull intendednull merged commit 81a3290 into main Jun 18, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant