Skip to content

chore(core): the animator plans Reduce Motion in its own module, shares a track per element and starts at the commit - #8056

Merged
cixzhang merged 135 commits into
meta:mainfrom
vjeux:wip/uikit-pr-m3b
Oct 27, 2026
Merged

cixzhang merged 135 commits into
meta:mainfrom
vjeux:wip/uikit-pr-m3b

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Update (head 2b264d3): size is now 14 files, 3,961 lines added, 623 removed; pnpm exec vitest run --project ui packages/core/src/motion: 14 files, 498 tests passed. The figures under Testing below are from an earlier head.

#8055 landed (30a4a2b); review the commits above main. play(curves) moved to #8521 and pseudo-element support to #8522.

User need

Touch presentations need Reduce Motion per design:reduced-motion, two components animating one row, and motion that survives a busy Mobile Safari.

Problem and solution fit

  • Reduce Motion follows DR1–DR4, planned by its own module (reducedMotionPlan.ts, a pure planner the animator applies): nothing moves, scales or springs; on a coarse pointer or after a touch release a change becomes an opacity-only 200 ms cross-fade, otherwise it completes at once, and no option or theme value changes that. Momentum after a throw keeps moving (DEC-2); a release past an edge cross-fades back on a coarse pointer and lands at once on a fine one; a change at once leaves a running fade to finish. reducedMotionFade() serves motion beside the animator.
  • animatorFor(element) shares one animator per element, so callers' axes compose in one track; atRest() waits out every curve; rebase(deltas) follows a layout shift, keeping springs.
  • A call from rest starts at the browser's commit, as Core Animation does; handle.started reports it.
  • Each animation and set() promotes its channels (will-change) in the same task, until 500 ms after rest.

Current specification

spec:AST-094 FR5, FR6, FR8; design:reduced-motion DR1–DR4.

Public delta and caller burden

None: internal and unused; the changeset is [chore].

Design and alternatives

  • Reading the browser's start rather than writing one: WebKit's compositor ignores a start written after creation (explicit starts remain for retargets and {at}).

Evidence

  • Unit tests cover each addition: the planner on its own, every Reduce Motion form, shared tracks, atRest across retargets, rebase, the browser-set start, and promotion.
  • iOS 26.2 simulator, a 600 ms block after the first commit: an explicit start on an unlayered element moved in 2 of 7 runs; promoted in the same task, 5 of 5; through this animator, 8 of 8. Chromium: promotion adds no Layout or Paint.

Writing rubric results

No record text of its own: AST-094 is current on main; R1–R6: none.

Scope

  • One capability is added.
  • Unrelated fixes, cleanup, and policy changes were removed or split.
  • Consumer docs, focused tests, and migration evidence ship with the feature.
  • Public text and artifacts contain no internal Meta context.

Testing

  • pnpm exec vitest run --project ui packages/core/src/motion: 14 files, 484 tests passed.
  • pnpm build, pnpm typecheck, ui and node suites, pnpm check:repo: clean.
  • pnpm -F @astryxdesign/core typecheck, strict lint, prettier, pnpm check:changesets, pnpm check:repo (pre-commit): clean.
  • Size: 15 files, 3,456 lines added, 558 removed.

Stack, in merge order: #8061, #8056, #8067, #8258, #8071, #8072, #8073, #8074, #8076, #8340; #8067 also follows #8505, #8348, #8045 and #8446. #8521 and #8522 stand on main.

@vercel

vercel Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 26, 2026 7:05pm UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟡 Reviewed (for maintainers only) Oct 26, 2026, 7:20 PM UTC

@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

Current run: The current CI run concluded cancelled. Current analysis is unavailable.

View Storybook for this PR · View Sandbox for this PR


Generated by PR Enrichment workflow | View current CI run

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can't be approved yet: every code change here rests on the draft spec:AST-094, which has no owner decision and also proposes touch-presentation amendments to current records (theme-tokens INV3, user-states DR1/DR3, the buttons family, interaction modality and AST-025 FR14). An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, or by saying which requirements must change; the author may do so as an engineering owner. Whatever that decision, a few changes are needed:

  • Under Reduce Motion, a move that starts while an earlier fade is still running leaves the surface at its mid-fade opacity for good, or at 0 with reducedMotion: 'instant' (animator.ts:879, :957); please end an untargeted fade at the opacity the surface is heading to and add a regression test.
  • Against main this pull request carries the whole stack, 6,370 authored lines, so landing #8052 to #8055 first would leave it at its own 599.
  • A few headers point at files that are not in the tree yet, such as __tests__/animator.compositor.spec.ts, sources.test.ts and uikit/switch.ts.

[Automated review]

Comment thread packages/core/src/motion/animator.ts Outdated
): Partial<Record<MotionProperty, Segment | null>> => {
const next: Partial<Record<MotionProperty, Segment | null>> = {};
const opacityNow = valueAt('opacity', time);
const opacityTo = targets.opacity ?? opacityNow;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Under Reduce Motion this ends a fade the call did not ask for at the opacity on screen. A move that starts while an earlier fade is still running (an entrance fade cut at 200 ms by a detent move) leaves the surface at 0.33 for good, and with reducedMotion: 'instant' the null opacity segment from line 957 makes animate and play store the pre-fade resting value, 0. Ending at finalValue('opacity') and leaving an untargeted opacity alone in 'instant' mode would fix both; a test that starts from a running fade would cover it.

Comment on lines +11 to +12
* @position Testing; the compositor itself is proven in
* __tests__/animator.compositor.spec.ts

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

__tests__/animator.compositor.spec.ts is not in this tree, so this header (and the SYNC entry in animator.ts:83) points at a proof that does not exist yet. Point it at the follow-up or drop it until the file lands.

…here it is

Under the Reduce Motion plan a call's travel waits in a landing, with no
segment, so handle.stop() stopped only the call's fade, and the fade's
landing then wrote the travel at once while the element was still visible.
The handle now stops the call's waiting travel too, before its fade, as
animator.stop() does: a catch freezes the element where it is on screen
(spec:AST-094 FR6). Tests: a stop mid-dip, and a release with one axis past
an edge, keep the travel where it was.
@vjeux

vjeux commented Oct 26, 2026

Copy link
Copy Markdown
Contributor Author

Two animator fixes and a comment line, offered separately:

1. A Reduce Motion call naming the opacity a waiting dip returns to plays a second dip (fork branch vjeux:uikit/f1-8056-join-dip, 670d985, one commit on 274ee1b). A call that names an opacity while a dip's move is still waiting to land takes the dip branch, so a call naming the opacity the dip already returns to (a confirm look's opacity landing, for one) opens a second dip from where the first had got to: mid fade out, the element fades out twice, and in the dip's own task the browser gets a second, identical fade. With the fix, such a call, moving nothing itself, joins the waiting dip: the planner returns joins, the fade goes on as it was, the call takes the landing over and settles when the dip ends, and the call it replaces lands with it. Three new tests, the planner's, mid dip and in the dip's own task, fail on 274ee1b and pass with it.

2. A call held before its first frame never starts (fork branch vjeux:uikit/f1-8056-held-start, e392079, one commit on 274ee1b). The animator reads a call's start at its first frame and when ready settles. If a sampler pauses the call's animations before that first frame (the interaction harness's hold can land there under load, and so can a page pausing every animation), both report no start and nothing looks again: a Reduce Motion dip waiting on the start never lands, and the element stays at the fade out's 0 after the run is finished or resumed. With the fix, while a call's animations are paused before it has a start, the animator looks again each frame, so a resume or a finish from outside resolves it. Two new tests, a hold then a finish and a hold then a resume, fail on 274ee1b and pass with it. Two browser proofs that hold in the call's own task, released with a finish and with a resume, fail 3/3 in Chromium and WebKit without the fix and pass 5/5 with it. It also fixes #8088's TreeList Reduce Motion failure in CI, where folders' fades held and released with play() stayed at opacity 0: the full Chromium suite fails that case 2 of 2 without the fix and passes 2 of 2 with it.

3. One comment line (fork branch vjeux:uikit/f1-8056-dec11-comment, 6acdb98, one commit on 274ee1b). reducedMotionPlan.ts says the cross-fade's length is "Not a theme role: no theme lengthens it"; under spec:AST-094 DEC-11 no motion role is themeable, so it reads "Not a role: nothing lengthens it".

@vjeux

vjeux commented Oct 26, 2026

Copy link
Copy Markdown
Contributor Author

A promotion replaces a stylesheet's will-change, so the element re-rasters when it starts and when it ends (fork branch vjeux:uikit/f1-8056-will-change, f9417c8, one commit on 274ee1b). The animator promotes a moving element with an inline will-change merged only with the element's inline value. On an element whose stylesheet already sets one (BottomSheetPanel: transform, opacity), the inline translate replaced it for the motion and handed it back at the end: two changes of the layer's reasons, so the whole sheet re-rastered at a drag's first move and again at its end. With the fix, a promotion adds its channels to the element's own will-change as its style computes it (transform, opacity, translate). That value is read in the task after the element's first frame and again after each demotion, so a drag's first move reads no style; only a motion that starts on an element attached in the same frame reads it in its own task. Two new tests fail on 274ee1b and pass with it. On a finger drag of the open "Snap points" sheet in Chromium with software raster, the tiles scheduled from the first move to the last go from 14 to 0, and from the lift to the settle from 21 to 16 (32 to 18 for a drag down and back). The rest of the lift's tiles come from the sheet's selection lock and its backdrop's dim, which are being fixed separately. On an Android emulator, the first move also needs that selection-lock fix: with both, its raster drops from 17 tiles to 0, and the lift's from 30 to 6, as on main.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs changes before it can be approved: the Reduce Motion planner follows design:reduced-motion and spec:AST-094, and the handle's stop no longer lands a dip in view, but two things remain.

  • Under Reduce Motion, when a second call starts before the first call's travel has landed, finished, stop() and set() act on the wrong call (a dismissal caught by a re-presentation reports finished === true, and stop() on a superseded handle leaves the element at x 0 instead of 200), so please key each carried landing value to its call in adoptLandings, cancelLanding, land and the handle's stop (animator.ts:1647-1660, :1766-1821, :1919-1925) and add regression tests.
  • At 4,116 authored lines, please move the start at the commit with promotion, animatorFor, atRest, rebase and the batched style read to their own pull request, with AST-094 FR5's Chromium and WebKit busy-main-thread proof, so that this one holds the Reduce Motion planner the line-count waiver describes.

[Automated review]

Comment thread packages/core/src/motion/animator.ts Outdated
Comment on lines +1771 to +1773
} else if (landing.values.delete(property) && !landing.owner.settled) {
// Set or stopped before it landed: the call did not finish.
settle(landing.owner, false);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Under Reduce Motion this settles landing.owner, the call that carries the landing, even when the removed value belongs to a carried call. In the animator's test harness, animate({x: 100}) and animate({y: 50}) in one frame, then set({x: 40}), settle the second call false and, once the dip lands, the first true; on main the first settles false and the second true. Keying each landing value to its call and settling that call here would fix it.

Comment on lines +1808 to +1813
for (const [property, value] of landing.values) {
if (!properties.includes(property) && !plan.values.has(property)) {
plan.values.set(property, value);
}
}
carried.push(landing.owner, ...landing.carried);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The values merge without their owner, and every earlier call is listed as carried, including one whose property the new call takes over, so land later settles it true (:1920-1925). A dismissal animate({y: 800, opacity: 0}) re-presented 50 ms in with animate({y: 0, opacity: 1}) then reports finished === true under Reduce Motion, where main and the no-preference path report false. A carried call whose property the new call overrides should settle false here, as interrupt does without Reduce Motion.

Comment thread packages/core/src/motion/animator.ts Outdated
Comment on lines +1653 to +1657
[...landings].some(
landing =>
(landing.owner === owner ||
landing.carried.includes(owner)) &&
landing.values.has(p),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

landing.values.has(p) is also true when the value is the newer call's own. After animate({x: 100}) and animate({x: 200}), the first handle's stop() deletes the second call's x from its landing, so the element stays at x 0 and the second handle settles false; without Reduce Motion, and on main, that stop does nothing and the element reaches 200. Checking that the value belongs to this call would keep a stale handle from stopping newer motion.

When a later call carried an earlier call's waiting move, the landing knew
only its own call: setting or stopping the carried move settled the later
call as interrupted (and the earlier one later as finished), an earlier
handle's stop could freeze the later call's move on the same property, and
a call whose property a later call took over still resolved finished. Each
landed value now keeps the call whose move it is: setting or stopping it
settles that call, a handle stops only its own values, and a move a later
call takes over settles interrupted. Three tests fail on the previous head.
@vjeux vjeux mentioned this pull request Oct 26, 2026
3 tasks done

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs changes before it can be approved: the Reduce Motion planner follows design:reduced-motion and spec:AST-094, and overlapping Reduce Motion calls now settle on the call whose move it is, but two things remain.

  • Under Reduce Motion, a move whose animations are paused before their first frame (a test harness's hold, a page pausing every animation) does not land on its own and leaves the element at opacity 0 with finished pending, because the call's start is read only in its first frame and at ready (animator.ts:1412-1416, :1882-1891); please keep reading it while the animations are paused without a start, with tests for a release by finish() and by a resume.
  • At 4,184 authored lines, please move the start at the commit with promotion, animatorFor, atRest, rebase and the batched style read to their own pull request, with AST-094 FR5's Chromium and WebKit busy-main-thread proof, so that this one holds the Reduce Motion planner the line-count waiver describes.

[Automated review]

// or when its compositor starts them (Chromium). Read it in that first
// frame, ahead of anything that might pause or seek the animations, and
// when `ready` settles.
requestFrame?.(() => syncGroup(group));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Besides ready, this is the only read of the call's start. When something pauses the call's animations before their first frame (a test harness's hold, a page pausing every animation), both see no start and nothing looks again, so the landing armed from handle.started (:1882-1891) never runs and the element stays at opacity 0 with finished pending. In Chromium with Reduce Motion and a coarse pointer, animate({x: 100}) paused in its own task and finished or resumed 30 ms later still shows opacity 0 and no translate 1 s later, where main lands it. Looking again each frame while the animations are paused without a start, or arming the landing from the call time when none arrives, with tests for a release by finish() and by a resume, would fix it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The held-start offer in #8056 (comment) fixes this: vjeux:uikit/f1-8056-held-start keeps reading the start each frame while the call's animations are paused or running without a start time, with two tests that fail without it.

// entrance that starts visible, or an exit that ends visible, dips instead
// when it has travel or a waiting landing to carry.
const carries = travel.length > 0 || pending;
if (entering && !pending && (!carries || fadeFrom <= 1e-6)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With any landing waiting this branch is skipped, so a call that takes over every waiting value still dips: animate({y: 800, opacity: 0}) re-presented 50 ms in with animate({y: 0, opacity: 1}) fades from 0.85 to 0 and back to 1, where main fades straight back up from 0.87. Reporting which properties wait, and ignoring those this call sets, would keep a caught dismissal from blinking.

Comment thread packages/core/src/motion/animator.ts Outdated
ownWillChange = null;
return;
}
ownWillChange ??= element.style.willChange;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ownWillChange is only the inline value, so the promotion's inline will-change replaces a stylesheet's for the motion's length (BottomSheetPanel's stylesheet sets willChange: 'transform, opacity'), changing the layer's reasons at the motion's start and end. Merging the computed value, as the fix offered in the conversation does, would keep them.

…s straight back; promotion keeps the stylesheet's will-change

- A call whose animations something paused before their first frame (a
  harness holding them, a page pausing every animation) never read a start,
  so a Reduce Motion move's landing never ran and the element stayed
  transparent. The call keeps looking each frame while its animations are
  paused without a start, until they resume (their start) or finish (its own
  clock).
- Under Reduce Motion a call that sets a value still waiting to land takes it
  over (the earlier call settles interrupted), and once nothing waiting is
  left, a call naming its own opacity replaces the earlier fade back in or
  restore: re-presenting a sheet mid-dismissal fades straight back up from
  where it was instead of dipping.
- Promotion merged only the element's inline will-change, so it replaced a
  stylesheet's will-change for the motion's length; without an inline value
  it now keeps the stylesheet's reasons beside its own and hands them back.
Three tests fail on the previous head.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs changes before it can be approved: the Reduce Motion planner follows design:reduced-motion and spec:AST-094, and a hold released by finish() now lands, but two things remain.

  • Under Reduce Motion, a move whose animations are paused before their first frame and then resumed with play() still never lands on its own in Chromium (opacity 0, finished pending), because the poll at animator.ts:1505-1516 stops once the animation is running without a start; please keep reading until the call has a start, and make the test stand-in start a resumed animation a frame later, as Chromium does.
  • At 4,320 authored lines, please move the start at the commit with promotion, animatorFor, atRest, rebase and the batched style read to their own pull request, with AST-094 FR5's Chromium and WebKit busy-main-thread proof, so that this one holds the Reduce Motion planner the line-count waiver describes.

[Automated review]

Comment thread packages/core/src/motion/animator.ts Outdated
if (
group.animations.some(
animation =>
animation.playState === 'paused' && animation.startTime == null,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After play(), Chromium reports 'running' with startTime null until the next frame, so this condition is false on the poll's next look and it stops before the start exists; the ready promise subscribed in commitCall already settled at the pause. In Chromium 149 under Reduce Motion, a move paused in its own task and resumed 30 ms later stays at opacity 0 with finished pending (3 of 3 runs). Keep looking while any live animation of the call has no start, paused or pending, or subscribe to the new ready after a resume.

async function commitFrame(at: number) {
for (const animation of recorded) {
if (animation.startTime == null && !animation.cancelled) {
animation.commit(at);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This commits a pending start before the frame callbacks run, so the poll in syncGroup sees a resumed animation's start in the same frame. Chromium sets it a frame later, which is why the 'resume' case at line 1501 passes here and fails in the browser. Starting a resumed animation one frame after its first frame callback would make that test catch it.

// Travel lands only while the element is transparent (DR1, DR2): an
// entrance that starts visible, or an exit that ends visible, dips instead
// when it has travel or a waiting landing to carry.
const carries = travel.length > 0 || pending;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pending also counts a landing whose values a later call already took over (it stays in landings unless the call names opacity), so animate({x: 100}) then animate({x: 0}) 50 ms in dips to 0 and back where main fades straight up, and a re-presentation with crossfadeAs: 'entrance' after an exit with crossfadeAs: 'exit' dips too. Counting only landings with values would let both fade straight back.

…l retargets settles interrupted

A call that named its own opacity and was carried by a later call naming
opacity never reached that opacity, yet settled finished once its travel
landed. It now settles interrupted when the later call takes its opacity
over, as without Reduce Motion. The test fails on the previous head.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs changes before it can be approved: the Reduce Motion planner follows design:reduced-motion and spec:AST-094, and the newest commit's settle fix is right, but two things remain.

  • Under Reduce Motion, a move whose animations are paused before their first frame and then resumed with play() can still stay at opacity 0 with finished pending in Chromium, because the poll at animator.ts:1509-1516 stops once the animation is running without a start; please keep reading until the call has a start (the held-start fix offered in the conversation does this), and make the test stand-in start a resumed animation a frame later, as Chromium does.
  • At 4,351 authored lines, please move the start at the commit with promotion, animatorFor, atRest, rebase and the batched style read to their own pull request, with AST-094 FR5's Chromium and WebKit busy-main-thread proof, so that this one holds the Reduce Motion planner the line-count waiver describes.

[Automated review]

The held-start poll stopped once the call's animations were no longer
paused, but an animation resumed with play() stays pending without a start
until a later frame (in Chromium, after the frame's callbacks), so a Reduce
Motion move could stay invisible. The call now keeps looking each frame
while any of its animations has no start and is not cancelled. The test's
stand-in now gives a resumed animation its start a frame later, as Chromium
does; it fails on the previous head.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs changes before it can be approved: the Reduce Motion planner follows design:reduced-motion and spec:AST-094, and the held-start fix is in, but two things remain.

  • At 4,363 authored lines, please move the start at the commit with promotion, animatorFor, atRest, rebase and the batched style read to their own pull request, with AST-094 FR5's Chromium and WebKit busy-main-thread proof, so that this one holds the Reduce Motion planner the line-count waiver describes.
  • Under Reduce Motion, decay cannot be told that a touch release started it, so a spring back from past an edge lands at once on a fine primary pointer where DR2 asks for a cross-fade; please forward touchRelease through decay and keep a move taken back mid-dip from blinking to opacity 0 (reducedMotionPlan.ts:162), each with a test.

[Automated review]

Comment thread packages/core/src/motion/animator.ts Outdated
@@ -1252,13 +2345,15 @@ export function createAnimator(
? animator.animate(transitions, REDUCED_TRANSITION, {at: time})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Under Reduce Motion this hands a spring back from past an edge to animate without touchRelease, and decay takes only at (line 313), so on a fine primary pointer a spring back that a touch drag's release started lands at once, where design:reduced-motion DR2 asks for the opacity-only cross-fade ("or when the release of a touch drag starts the transition"). Accepting touchRelease in decay's options and passing it here, with a test on a fine pointer, would conform; the changeset, the README, the header and coast.ts would then say the same.

}
// A move still waiting to land (an earlier dip's) lands at this call's
// invisible moment, never while the element shows.
const pending = view.hasPendingLanding;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hasPendingLanding is also true for a landing whose values a later call already took over (takeOverLandings leaves the emptied landing in the set when the call names no opacity), so a take-back such as animate({x: 100}) then animate({x: 0}) 50 ms in dips from 0.5 to 0 and back, where main fades straight up from 0.5. Counting only landings that still hold values would let it fade straight back; an entrance by role caught mid-exit also opens its fade at 0 (line 152).

… an entrance catching an exit starts where it is

- decay accepts touchRelease and hands it to the spring back's transition,
  so a spring back a touch drag's release starts cross-fades on a fine
  pointer too (design:reduced-motion DR2); the README and coast.ts say so.
- A move taken back mid-dip (animate({x: 100}) then animate({x: 0})) left
  an emptied landing that kept the dip going down to 0 and back; a dip
  left with nothing to land now fades straight back in from where it is.
- An entrance by role caught mid-exit opened its fade at 0 and headed for
  the exit's 0; it now starts from the opacity on screen and fades back to
  what the exit would have restored, the exit's landing taken over as a
  named opacity's is.
Three tests fail on the previous head.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs changes before it can be approved: the Reduce Motion planner follows design:reduced-motion and spec:AST-094, and this head's fixes for decay's touch release, a mid-dip take-back and a caught exit by role are in and tested, but the pull request still carries about 1,370 added lines of separable work outside the line-count waiver for the planner.

  • Please move the start at the commit with promotion, animatorFor, atRest, rebase and the batched style read to their own pull request, with AST-094 FR5's Chromium and WebKit busy-main-thread proof, so that this one holds the Reduce Motion planner the waiver describes.
  • Please also say in .changeset/uikit-motion-animator.md and the animator header that a touch-released spring back cross-fades on any pointer, and refresh the description's size and test count.

[Automated review]

Comment thread .changeset/uikit-motion-animator.md Outdated
---

[chore] Internal: a compositor animator for touch presentations. It plays UIKit's springs, and scroll coasts (built by a separate coast module, so a surface that never coasts does not carry them; `decay` plays a release's momentum, and under Reduce Motion cross-fades a spring back from past an edge) as Web Animations on translate, rotate, scale and opacity, so they keep running while JavaScript is busy, and reads values and velocities back from the curves for interruption, retargeting and Reduce Motion cross-fades. Not exported from the package entry point; no component uses it yet.
[chore] Internal: a compositor animator for touch presentations. It plays UIKit's springs, and scroll coasts (built by a separate coast module, so a surface that never coasts does not carry them; `decay` plays momentum, and sends a spring back from past an edge through the Reduce Motion policy: a cross-fade on a coarse pointer, at once elsewhere) as Web Animations on translate, rotate, scale and opacity, so they keep running while JavaScript is busy, and reads values and velocities back from the curves for interruption, retargeting and Reduce Motion cross-fades. Not exported from the package entry point; no component uses it yet.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With decay now taking touchRelease, a spring back from past an edge also cross-fades on a fine pointer when a touch drag's release starts it (design:reduced-motion DR2). "a cross-fade on a coarse pointer, at once elsewhere" leaves that out; "a cross-fade on a coarse pointer or after a touch release, at once elsewhere" matches the code, the README and coast.ts.

Comment thread packages/core/src/motion/animator.ts Outdated
Comment on lines +122 to +124
* picks (a detent, a page), is a transition: it plays through the Reduce
* Motion policy above, a cross-fade on a coarse pointer and at once
* elsewhere (DR2, DR3).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same here: decay's own docs at lines 300-312 say a spring back cross-fades on any pointer when touchRelease is passed, but this paragraph still says "a cross-fade on a coarse pointer and at once elsewhere". Adding "or after a touch release (touchRelease)" keeps the header in step.

…cross-fade for a spring back

With decay taking touchRelease, a spring back from past an edge also
cross-fades on a fine pointer when a touch drag's release starts it
(design:reduced-motion DR2); the header and the changeset now say so, as
decay's docs, the README and coast.ts do.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs changes before it can be approved: the Reduce Motion planner follows design:reduced-motion and spec:AST-094, but the pull request still carries about 1,370 added lines of separable work that the line-count waiver for the planner does not cover.

  • Please move the start at the commit with promotion, animatorFor, atRest, rebase and the batched style read to their own pull request, with AST-094 FR5’s Chromium and WebKit busy-main-thread proof, or explain why they cannot be split.
  • Please also refresh the description’s size, test count and “One capability is added” box, and its line saying a release past an edge lands at once on a fine pointer, which leaves out the touch-release cross-fade.

[Automated review]

};

// -- segment factories ------------------------------------------------------
// -- starting from rest ---------------------------------------------------

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section (the start at the commit: StartGroup, handle.started, the held reads, through resolveGroup), with the promotion block above it, is separable from the Reduce Motion planner: the planner arms its landing from handle.started, which a call with an explicit start resolves at once. Please move it to its own pull request with AST-094 FR5’s busy-main-thread proof, or explain why it must land with the planner.

// are checked where the group declares them too.
const bindings = /^const \{([^}]+)\} =\s*(?:\n\s*)?(\w+);/gm;
for (const [, names, group] of text.matchAll(bindings)) {
for (const [, names, group] of lines.join('\n').matchAll(bindings)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lines is text.split('\n') (line 61), so lines.join('\n') is the same string as text: this edit changes nothing and can go.

lines.join('\n') is the text it was split from; the edit changed nothing
and goes back to main's line.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs changes before it can be approved: the Reduce Motion planner follows design:reduced-motion and spec:AST-094, but the pull request still carries 1,369 added lines of separable work that the line-count waiver for the planner does not cover.

  • Please move the start at the commit with promotion, animatorFor, atRest, rebase and the batched style read to their own pull request, with AST-094 FR5's Chromium and WebKit busy-main-thread proof, or explain why they cannot be split.
  • Please also correct the description's checked "One capability is added" box, and its line saying a release past an edge lands at once on a fine pointer, which leaves out the touch-release cross-fade.

[Automated review]

@vjeux

vjeux commented Oct 26, 2026

Copy link
Copy Markdown
Contributor Author

The first offer above (a Reduce Motion call naming the opacity a waiting dip returns to plays a second dip) is refreshed onto this pull request's head: fork branch vjeux:uikit/f1-8056-join-dip-2b264d3d, c1008d1, one commit on 2b264d3.

The defect is still live at 2b264d3, and a swipe row's release hits it: its move plays as a dip, then its state layer lands opacity 1 on the same element in the same task, and each entry but the outermost fades out twice (its release storyboard records two identical opacity curves for such an entry). The planner's join is unchanged from the first offer; the animator's side now follows this head's landing ownership, so the call it replaces is carried and each value keeps the call whose move it is. A call that names travel, or another opacity, plans as before. Three new tests, the planner's, mid dip and in the dip's own task, fail on 2b264d3 and pass with it; the motion and gestures suites pass (614 of 614).

The same fix rides on #8076, the experimental touch layer that carries this pull request, so the swipe storyboard can be re-recorded there.

@vjeux

vjeux commented Oct 26, 2026

Copy link
Copy Markdown
Contributor Author

The other two offers in that comment, against this pull request's head:

will-change. The second fix's main point, keeping a stylesheet's will-change reasons while the animator promotes, is covered by this head's own change. One part remains, offered on its own (fork branch vjeux:uikit/f1-8056-sheet-will-change-read, 2d66cf2, one commit on 2b264d3): a promotion still reads the stylesheet's value with getComputedStyle as it starts, so a drag's first move forces a style recalculation inside the input handler. With the commit, the value is read in a task of its own after the element is attached, and again after each demotion, and a promotion uses what was read; one that comes before that read (an element attached in the same task that moves at once) still reads it then. A test counting style reads in the promoting task fails on 2b264d3 and passes with it; the motion and gestures suites pass (612 of 612).

DEC-11 comment line. Unchanged, refreshed onto this head (fork branch vjeux:uikit/f1-8056-dec11-comment-2b264d3d, 008db3a): reducedMotionPlan.ts reads "Not a role: nothing lengthens it" instead of "Not a theme role: no theme lengthens it".

Both also ride on #8076's next update.

This branch was successfully deployed

1 active deployment
Preview — 2b264d3d Deployed Oct 26, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot. needs:design-review Affects visuals — Design should review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants