Skip to content

fix(blur): ghosts stop stealing flex slots, and blur gains an axis - #401

Merged
LeadcodeDev merged 2 commits into
mainfrom
fix/motion-and-directional-blur
Sep 27, 2026
Merged

LeadcodeDev merged 2 commits into
mainfrom
fix/motion-and-directional-blur

Conversation

@LeadcodeDev

Copy link
Copy Markdown
Owner

Closes #359. Closes #360. Refs #388.

#359 — three faults, all in ghost construction

Ghosts stole flex slots — a regression of #66. base_css_for_ghost only set position: absolute when the principal was already absolute, so a flow child's ghost kept flow-participating CSS and became an extra full-size flex item. Ghosts are now unconditionally absolute; an inset-less absolute child is resolved by justify/align, so it consumes no slot.

That last fact was verified empirically against Taffy 0.10.1's flexbox source before the fix was committed to — it is real but undocumented behaviour, and worth knowing for anyone who touches ghost positioning again.

the motion-blurred box itself must sit at the flex-resolved position (110), got 285

Ghosts dropped their children and lost intrinsic size — they were built with children: Vec::new() and intrinsic: None. A card's label never trailed, and a measured leaf with no explicit width collapsed to 0×0 and painted nothing. Each ghost now builds its own children at its own sampled time and carries its own intrinsic.

pointer.path was never sampled. Pointer::paint_content reads ctx.time at dispatch, not the baked transform, and ghosts reused the principal's time_params entry — so re-evaluating CSS at the ghost's instant during build changed nothing. Each ghost now gets its own entry. Pre-fix, all four ghosts sampled the identical offset (1020.37036, 560.1852).

#360 — blur gains an axis

radius-x/radius-y on the existing blur filter (falling back to radius per axis), a new directional-blur filter that rotates about the box centre and blurs on one axis, animatable blur_x/blur_y, and motion_blur mode: "smear" which skips ghosts entirely and applies the node's own displacement over the shutter window as a directional blur.

Three parts deliberately skipped, and named in the rule docs rather than silently dropped: scope: "self"|"subtree" (no repro needed it), samples: "auto" (superseded by smear, which the issue itself preferred), and per-character blur_axis/stretch on char_* presets — that last one lives in the text components, and adding the schema field without a consumer would be exactly the inert-field anti-pattern #339, #349 and #364 were all about.

A latent bug found on the way, not fixed here

FilterFn's #[serde(tag = "fn", rename_all = "kebab-case")] renames variant tags only, not struct-variant field names in this serde version. It surfaced because radius-x silently deserialised to None until an explicit #[serde(rename)] was added.

That means FilterFn::DropShadow's offset_x/offset_y are almost certainly unreachable as offset-x/offset-y in JSON today — no test or skill doc exercises them either way. Untouched here because it is unrelated to these two issues, but it deserves its own audit.

Verification

Six new tests plus three JSON round-trips, each proven red by reverting. cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace (1668) all clean.

paint_pass.rs is shared and was touched minimally: both filters_to_image_filter call sites now pass box_layout (the rotation pivot), three match arms for the new fields and variant, and two private helpers. css/animation.rs needed two mechanical lines once FilterFn::Blur gained fields — flagged by the agent rather than buried.

Written comment-free, per the codebase-wide rule from #345.

…e their own clock (#359, #360)

build_ghosts (box_builder.rs) had three defects reported as one issue,
all traceable to the same root: a ghost was never given its own position,
its own children, or its own paint-time clock.

- Regression of #66: base_css_for_ghost only set `position: absolute` when
  the principal itself already was, so a ghost of a flow child kept the
  principal's flow-participating CSS and became an extra full-size flex
  item -- `samples` copies pushing every later sibling out of place. A
  ghost is now unconditionally `position: absolute`; one already-absolute
  gets its exact left/top, a flow one falls back to Taffy's justify/align
  resolution for an inset-less absolute child, which never consumes space.

- A ghost of a container was built with `children: Vec::new()`, so only
  the container's own background/border ever traveled with the trail --
  a card's label, a mockup's screenshot, never did. build_one_ghost now
  calls the existing `container_children` at the ghost's own sampled time
  (via a `(scale, shift)` remap identical in shape to the principal's, just
  re-anchored so `ghost_time` plays the role `actx.time` normally does),
  so nested content is rebuilt, not copied. A ghost of a *measured* leaf
  (text, counter, badge, table, rich_text, ...) also picks up its own
  `component_intrinsic`, which the old ghost never set -- without it the
  box measured to zero and nothing painted, the "shape trails, text
  doesn't" half of the same issue.

- A `pointer` moved by `path` reads its own position from `ctx.time` inside
  `paint_content`, not from `style.transform` -- so re-evaluating its CSS
  at a ghost's sampled time did nothing, since dispatch recomputes
  `ctx.time` itself from `time_params` at paint time. Every ghost now gets
  its own `time_params` entry, shifted by exactly its own sample offset,
  so `LegacyPaintDispatcher::dispatch`'s `local_time` -- and therefore
  `ctx.time` handed to `paint_content` -- lands on the ghost's own instant
  for *any* effect that reads live time at dispatch, not just transforms
  baked in at build time.

Each of the three has a test named for its fault in box_builder.rs's tests
module (ghost_does_not_steal_a_flex_slot,
ghost_of_a_container_carries_its_nested_child +
ghost_of_a_measured_leaf_keeps_its_intrinsic_size,
ghost_of_a_path_driven_pointer_samples_a_different_waypoint_offset), each
reverted against the pre-fix build_ghosts and confirmed red before being
restored.

#360's directional blur rides the same fix, since a smear kernel needed
motion_blur's internals anyway:

- `FilterFn::Blur` gains optional `radius-x`/`radius-y` (falling back to
  `radius` per-axis when absent -- fully backward compatible), and a new
  `FilterFn::DirectionalBlur { angle, radius }` for the diagonal case,
  rendered in paint_pass.rs by rotating the sampled content around the
  box's own center, blurring on one axis, and rotating back.
- `blur_x`/`blur_y` join `KNOWN_MOTION_PROPERTIES` and `AnimatedProperties`
  as animatable keyframe properties, surfaced as the same `Blur` filter via
  a new box_builder.rs helper (apply_animated_props in css/animation.rs
  isn't in this workstream's owned files, so the isotropic `blur` path
  there is untouched and the new axes are composed alongside it instead).
- `motion_blur mode: "smear"` skips the ghost sampler entirely: it measures
  the node's own displacement over the `shutter / fps` window and applies
  that as a `Blur { radius-x, radius-y }` filter on the principal -- the
  "real kernel" the issue preferred over stacking more visible copies.

Touched crates/rustmotion-core/src/engine/paint_pass.rs (shared): the two
`filters_to_image_filter` call sites now pass `box_layout` (needed for
`DirectionalBlur`'s rotation pivot), the `Blur`/`DirectionalBlur` match arms
in `filter_bleed`/`filters_to_image_filter`/`color_matrix_for`, a new
`resolve_blur_radii` + `directional_blur_image_filter` helper, and three
existing `FilterFn::Blur { radius }` test literals updated for the new
fields. Also one mechanical line in css/animation.rs's
`apply_animated_props` (outside owned files) to add the two new `Blur`
fields to its existing isotropic-blur construction site, unavoidable once
`FilterFn::Blur` gained fields.

Not implemented, and named as such in the new rule docs: a `scope`
toggle to ghost only a container's own box without its children (every
container ghost now always carries its subtree; no scenario needed the
opt-out), `samples: "auto"` densification (superseded by `mode: "smear"`),
and per-character `blur_axis`/`stretch` on the char_* presets (lives in
text.rs/renderer/text.rs, outside this workstream's owned files -- wiring
the schema field without a consumer would be exactly the inert-field
pattern this codebase avoids elsewhere).

Two new French rule docs land under skills/rules/
(motion-blur-and-trail.md, directional-blur.md); SKILL.md is deliberately
left unlinked since the orchestrator owns that index.
@LeadcodeDev LeadcodeDev added the bug Something isn't working label Sep 27, 2026
@LeadcodeDev LeadcodeDev self-assigned this Sep 27, 2026
@LeadcodeDev
LeadcodeDev merged commit 068f0f6 into main Sep 27, 2026
4 checks passed
@LeadcodeDev
LeadcodeDev deleted the fix/motion-and-directional-blur branch September 29, 2026 18:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

1 participant