Skip to content

chore(core): a compositor animator for touch presentations - #8055

Merged
cixzhang merged 69 commits into
meta:mainfrom
vjeux:wip/uikit-pr-m3
Oct 26, 2026
Merged

cixzhang merged 69 commits into
meta:mainfrom
vjeux:wip/uikit-pr-m3

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

#8054 merged; the keyboard duration's export moved to #8484 (reverted here).

User need

A touch presentation has to keep moving while JavaScript is busy, and a finger has to catch it mid-flight.

Problem and solution fit

createAnimator(element) (spec:AST-094 FR5, FR6, FR8):

  • It bakes each curve (a spring, or a coast with an edge bounce that coast() builds) into Web Animations keyframes on translate, rotate, scale and opacity, played with easing: linear on one explicit start time, so the browser runs them off the main thread.
  • get / getVelocity read the curve with no layout read; animate starts from the presentation value and velocity; a handle reports finished.
  • The end state lives in the inline style and the keyframes hold it past the curve's end, so no frame flickers, even in WebKit during a block.
  • Under Reduce Motion a change fades opacity only within 200 ms (reducedMotionCrossFade, not the timing table) on a coarse pointer and completes at once on any other (design:reduced-motion DR2–DR4); momentum after a throw keeps moving (DEC-2).
  • The animator and its Reduce Motion substitution are one capability: under Reduce Motion every call runs through it.

Current specification

spec:AST-094 FR5, FR6, FR8.

Public delta and caller burden

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

Design and alternatives

  • Keyframes rather than linear() easing: WebKit runs linear() on the main thread (iOS 26.2 simulator: 1 of 21 frames moved through a block, against 20 of 21 for baked keyframes).
  • Individual properties rather than transform: a finger can drive translate while scale keeps springing.
  • The coast lives in coast.ts, so a surface that never coasts skips its math and the scroll table; a bundle test keeps the timing table's other rows out (INV6).

Evidence

Unit tests against a recording stand-in for Element.animate: keyframes within 0.06 px of the analytic curve; the inline end state and shared clock; velocity continuity across retargets; stop and finished; every Reduce Motion case; a coast reaching an edge; a late attach; a paused animation never retired by the wall clock. Browser proofs: #8067. Production bundle: the animator alone, 4,740 B gzip.

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: 12 files, 411 tests passed.
  • pnpm build, pnpm typecheck, ui and node suites, pnpm check:repo: clean.
  • pnpm -F @astryxdesign/core typecheck: clean.
  • ASTRYX_STRICT_LINT=1 pnpm exec eslint packages/core/src/motion: clean.
  • Prettier, pnpm check:changesets, pnpm check:repo (pre-commit): clean.
  • Size: 10 files, 2,954 lines added, 30 removed (the coast module's split is waived).

Stack, in merge order: #8053, #8058, #8059, #8060, #8061, #8054, #8257, #8055, #8056, #8067, #8258, #8071, #8072, #8073, #8074, #8076, #8340; #8067 also follows #8043, #8044, #8348, #8045 and #8446. #8062 is 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 11:27am 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, 11:49 AM UTC

@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR

🧪 Sandbox Preview

View Sandbox for this PR

No new or modified components detected.

Bundle Size Summary

Gzip figures measure each package's entry file, not the code an import pulls in.

Package Entry (ESM) Entry gzip before Entry gzip after Delta
@astryxdesign/core 4.9KB 1.2KB 1.2KB 0B
Package stylesheet Gzip before Gzip after Delta
@astryxdesign/core 34.3KB 34.3KB 0B

Accessibility Audit

Status: No accessibility violations detected.

Visual Regression

55 of 502 shot(s) changed.

A change here is a question, not a failure: check whether the after is the
picture you intended. Record that review on the PR. Baseline maintenance is an
explicit dispatch of CI; this report never rewrites the baseline or adds a release gate.

component story theme mode pixels
Selector Indicator Space Bottom Sheet Narrow End RTL neutral light 753,933
Selector Indicator Space Bottom Sheet Wide Start neutral light 740,072
RichTextEditor Markdown parity: side by side probe dark 558,840
RichTextEditor Markdown parity: side by side probe light 540,756
Chat Full AI Chat probe light 230,823
Chat Full AI Chat probe dark 229,805
AppShell With Mobile Nav probe light 217,010
AppShell With Mobile Nav probe dark 216,960
Collapsible Dividers probe dark 66,445
Collapsible Dividers probe light 66,445
AppShell Playground neutral dark 65,198
AppShell Playground neutral light 63,788
Chat Default neutral dark 36,304
Chat Default neutral light 35,767
Chat Empty State probe light 23,531
Chat Empty State probe dark 22,622
ChatAutoScroll Scroll Behavior Comparison neutral dark 15,378
ChatAutoScroll Scroll Behavior Comparison neutral light 14,107
Chat With Attachments probe light 13,406
Chat With Attachments probe dark 11,232

and 35 more.


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

@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.

Approval waits on owner decisions about the draft spec:AST-094 that this code implements: an engineering owner needs to approve or reject its new requirements (FR1–FR23, DEC-1, DEC-2) by commenting /approve-spec <full head SHA> on this pull request (the author can do this as an engineering owner), and the current records it intentionally changes need updates an engineering owner approves: theme-tokens INV3 (FR4, touch motion stops following the theme), user-states DR1 and DR3 (FR11, press profiles) and AST-025 FR14 (FR14, no scroll chaining mid-drag on touch). Whatever that decision, three changes are still needed:

  • Under Reduce Motion, moving an element while its fade is running leaves its opacity at the mid-fade value (packages/core/src/motion/animator.ts:843-844); end the fade at the committed opacity target and add a test for that case.
  • The diff carries the whole stack (5,881 lines); land #8052–#8054 first so this pull request shows only the animator commit, and split out the modules nothing imports yet.
  • Add the ## Writing rubric results section that the knowledge-contracts architecture requires for a new record.

[Automated review]

Comment thread packages/core/src/motion/animator.ts Outdated
Comment on lines +843 to +844
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 takes the dip's end opacity from the value on screen when the call names no opacity, and replaces a fade that is still running. Moving an element 120 ms into a fade-out leaves it at opacity 0.5552 instead of 0 (an entrance stays at 0.4448 instead of 1), and the fade's finished resolves false. Ending at the committed target keeps the change of state, and entering and leaving then read correctly:

const opacityTo = targets.opacity ?? finalValue('opacity');

A Reduced Motion test that moves during an in-flight fade would cover it.

@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.

Changes requested: the Reduce Motion re-attach and opacity fixes hold up, but the branch needs a rebase and the diff is still over the 2,000-line limit.

  • main rewrote the scroll-to-top note in packages/core/src/motion/uikit/scroll.ts (#8494) right beside the line this pull request edits, so GitHub reports a merge conflict and none of the repository CI has run on this head; rebase and keep #8494's Reduce Motion wording.
  • The diff is 2,795 authored lines and coast.ts is the cut you named, so move it with coast.test.ts and the decay tests that call coast() into a pull request of its own.

[Automated review]

* approaches the top as 1 − e^(−9.7 t) whatever the distance (done in about
* 1.6 s for 500, 2000 or 8000 pt), which is a decay at this per-millisecond
* rate (e^(−0.0097)) whose rest point is the top: a decay with a
* rate (e^(−0.0097)) whose rest point is the top: a coast (coast.ts) with a

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

#8494 on main rewrote the rest of this note (it now says a tap-started decay cross-fades or jumps under Reduce Motion, so a component checks for Reduce Motion before it plays it), and this line conflicts with that change: GitHub reports the pull request as not mergeable, so no repository CI has run on this head. Rebase onto main and keep #8494's wording.

…the animator

# Conflicts:
#	packages/core/src/motion/uikit/scroll.ts

@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.

Changes requested: the merge with main resolved the scroll-to-top conflict, and the animator's compositor playback, catch and Reduce Motion behavior hold up in the tests and in Chromium, but the diff is still 2,795 authored lines with a module that can stand on its own.

  • Move coast.ts with coast.test.ts and the decay tests that build curves with coast() into a pull request of their own, the split you offered; what remains is judged as one capability under spec:AST-094 FR5, FR6 and FR8.
  • Minor: packages/core/src/motion/uikit/scroll.ts:115 now routes the tap-started scroll-to-top through coast.ts, against decay's momentum-only contract, and animator.ts:81 names a test file that does not exist.

[Automated review]

* approaches the top as 1 − e^(−9.7 t) whatever the distance (done in about
* 1.6 s for 500, 2000 or 8000 pt), which is a decay at this per-millisecond
* rate (e^(−0.0097)) whose rest point is the top: a decay with a
* rate (e^(−0.0097)) whose rest point is the top: a coast (coast.ts) with a

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 line now builds the tap-started scroll-to-top as a coast from coast.ts, a curve only decay plays. MotionCurve (animator.ts:107–114) and the changeset say momentum is the only curve decay plays and every other motion goes through animate, and lines 116–121 call this a transition under Reduce Motion. Keeping main's "a decay" here, or reconciling the two texts, leaves its implementer one route.

…ted decay, not a coast

decay plays momentum only, and a tap-started scroll to top is not a throw's
momentum; main's note already says so (meta#8494).

@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.

Changes requested: the animator's compositor playback and catch follow spec:AST-094, but two Reduce Motion paths land travel in full view, the branch no longer merges with main, and the diff still carries the coast module you offered to split out.

  • Under Reduce Motion on a touch screen, a stylesheet opacity of 0 that one move borrows stays borrowed, so a later move lands at once at opacity 1 after the stylesheet shows the element (packages/core/src/motion/animator.ts:1069-1072, :1189-1190), and a stop that finishes a fade leaves a pending jump to land in view (:1232-1238); keep a borrow only while its dip runs, land or freeze hidden travel on a stop, and add coarse-pointer tests for both.
  • #8257 landed on main and conflicts with this branch in packages/core/src/motion/README.md and uikit/uikit.test.ts, so merge main, port the binding check into its leavesOf, and keep its README wording.
  • Move coast.ts, coast.test.ts and the decay tests that build curves with coast() into a pull request of their own, the split you offered, so the remaining animator can be judged as one capability under spec:AST-094 FR5, FR6 and FR8.

[Automated review]

Comment thread packages/core/src/motion/animator.ts Outdated
Comment on lines +1189 to +1190
borrowedOpacity =
borrowed ?? (targets.opacity == null ? previousBorrow : 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.

Under Reduce Motion on a coarse pointer this keeps a borrow even when no dip ran: a move while the stylesheet's opacity is 0 lands at once with no opacity segment, yet borrowedOpacity stays 0. Every later call then reuses it (borrowedOpacity ?? ownOpacity() keeps a 0) and lands its travel at once, even after the stylesheet shows the element, so the move is seen at opacity 1 with no cross-fade (design:reduced-motion DR2). Keeping the borrow only while the dip that took it runs, and reading ownOpacity() again otherwise, fixes it.

Comment on lines +1232 to +1238
// A Reduce Motion fade is never left half done: it finishes, and a
// dip on the stylesheet's opacity hands that opacity back.
const value = !segment.isFade
? valueAt(property, time)
: wasBorrowed
? IDENTITY.opacity
: segment.target;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Finishing a fade here leaves another property's pending jump scheduled: stop(['opacity']) 50 ms into a move's dip, or the newest handle's stop() during overlapping moves, lets that jump land 50–80 ms later at opacity 1. Landing or freezing the travel the fade was hiding keeps it out of view.

…es a dip lands its move with it

Under Reduce Motion on a touch screen:
- A move over a stylesheet opacity of 0 dips nothing (it lands at once), but
  kept 0 as a borrow that outlived it, so a later move once the element
  showed read it as invisible and landed in view. A borrow now exists only
  while its dip runs.
- stop() on a dip's opacity finished the fade but left the move's pending
  jump to land later, at opacity 1. A finished fade now lands the travel
  waiting for it at the same instant.
…nimator

# Conflicts:
#	packages/core/src/motion/README.md
#	packages/core/src/motion/uikit/uikit.test.ts

@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.

Changes requested: the animator's compositor playback, catch and Reduce Motion fades follow spec:AST-094, and the Reduce Motion fixes since the last review hold, but one Reduce Motion path still travels in view and the diff is still over the size limit.

  • Under Reduce Motion on a touch screen, decay(coast(…)) springs a release past an edge back with full-opacity travel for about 750 ms (packages/core/src/motion/coast.ts:105-115), where design:reduced-motion DR2 cross-fades a transition started on release, so send that return through animate and add a coarse-pointer test.
  • At 2,846 authored lines, move coast.ts, coast.test.ts and the decay tests that build curves with coast() into a pull request of their own, the split you offered, and say in the description why the remaining animator and its Reduce Motion substitution are one inseparable capability.

[Automated review]

Comment on lines +105 to +115
if (bounds != null && (from < bounds.min || from > bounds.max)) {
// Released past an edge: straight back with the release velocity.
const edge = from < bounds.min ? bounds.min : bounds.max;
const back = solveSpring(bounce, {from, to: edge, velocity: v0});
curves[key] = settling(
back.settleTime(threshold),
edge,
t => back.value(t),
t => back.velocity(t),
);
continue;

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 spring plays through decay, which skips the substitution, so a release past an edge with no throw springs back with travel at opacity 1 for about 750 ms in Chromium, the same as with no preference. design:reduced-motion DR2 exempts only momentum (“a decay coast and its settle at an edge”) and cross-fades a transition started on release, such as a swiped row returning. Sending this return through animate (for example, returning no curve for this property so the caller springs it back with animate) and adding a coarse-pointer Reduce Motion test would conform it.

Comment thread packages/core/src/motion/animator.ts Outdated
*
* SYNC: When modified, update:
* - /packages/core/src/motion/animator.test.ts
* - /packages/core/src/motion/__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 does not exist at this head (animator.test.ts:13 names it too); pointing this at the browser test that lands with the first adopter, or dropping the line, keeps the SYNC list true.

…ross-fade, not as momentum

Under Reduce Motion on a touch screen, decay(coast(...)) for a release past
an edge sprang back with travel at full opacity. That spring is a transition
the release starts, not a throw's momentum, so DR2's momentum exemption does
not cover it. coast marks such a curve `isTransition`, and decay hands it to
animate's Reduce Motion path (a cross-fade on a coarse pointer, at once on any
other); a coast that reaches an edge keeps its momentum, bounce included.

@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.

Changes requested: the animator's compositor playback, catch and Reduce Motion fades follow spec:AST-094 and design:reduced-motion, and a release past an edge now cross-fades under Reduce Motion on a touch screen, but the diff is still over the size limit.

  • At 2,910 authored lines, move coast.ts, coast.test.ts and the animator.test.ts tests that build curves with coast() into a pull request of their own, the split you offered, and say in the description why the remaining animator and its Reduce Motion substitution are one inseparable capability.
  • Update decay's documentation (packages/core/src/motion/animator.ts:109-111, :200) and the changeset, which still say Reduce Motion never applies to decay and that it plays only momentum, and point the SYNC list at a test file that exists.

[Automated review]

Comment thread packages/core/src/motion/animator.ts Outdated
/**
* Plays momentum curves as given, one per property: a released scroll
* view's coast and edge bounce, which `coast()` (coast.ts) builds.
* Reduced Motion does not apply: a throw continues the finger. Only a

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

decay now cross-fades a curve marked isTransition under Reduce Motion (lines 1219–1231), so this sentence, MotionCurve's doc (lines 109–111) and the changeset's "momentum is the only curve decay plays" no longer describe it. Saying that decay plays momentum as given and cross-fades a curve marked isTransition keeps internal callers from expecting a transition they pass here to travel.

Comment thread packages/core/src/motion/animator.ts Outdated
* ```
*/
/**
* The transition `decay` hands a Reduce Motion transition curve to: under

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 block now sits between createAnimator's doc comment (lines 433–446) and the function, so TypeScript attaches that comment and its @example to nothing. Moving the constant above the comment keeps the function documented.

…ase is one handle, and REDUCED_TRANSITION keeps createAnimator's docs

- attach rebuilt only channels away from identity, so an identity the
  animator held inline over a stylesheet that differs (set({opacity: 1}) over
  opacity 0) was never written on the node that replaced the element; the
  new node showed 0 while get() read 1. The held identities now carry through
  a detach to the next node.
- decay under Reduce Motion with one axis past an edge and another coasting
  returned a handle that covered only the coasting axis; stop() left the
  cross-fade to land. The handle now covers both calls.
- REDUCED_TRANSITION moves above createAnimator's doc comment.

@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.

Changes requested: the animator's compositor playback, catch and Reduce Motion handling follow spec:AST-094 and design:reduced-motion, and decay's docs and the changeset now describe the Reduce Motion cross-fade, but the diff is still over the size limit.

  • At 2,984 authored lines, move coast.ts, coast.test.ts and the animator.test.ts tests that build curves with coast() into a pull request of their own, the split you offered, and say in the description, with a corrected size line, why the remaining animator and its Reduce Motion substitution are one inseparable capability.
  • Point the SYNC list (packages/core/src/motion/animator.ts:81) and the test header (animator.test.ts:12-13) at a file this pull request contains, since __tests__/animator.compositor.spec.ts arrives only with #8067.

[Automated review]

Comment thread packages/core/src/motion/animator.ts Outdated
*
* SYNC: When modified, update:
* - /packages/core/src/motion/animator.test.ts
* - /packages/core/src/motion/__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.

This SYNC entry, and the @position note in animator.test.ts:12-13, name __tests__/animator.compositor.spec.ts, which this pull request does not contain (it arrives with #8067). Drop both here and add them with the file, or name a file this head has.

* );
* ```
*/
export function coast(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

At 2,984 authored lines the diff is over the 2,000-line limit, and this module, with coast.test.ts and the seven animator.test.ts tests that build curves with coast(), is the separable cut you offered. Moving it into its own pull request leaves the animator and its Reduce Motion substitution, whose inseparability the description then needs to state.

…s this layer has, and coast says a transition lands at once on a fine pointer

@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.

Changes requested: the animator's compositor playback, catch and Reduce Motion handling follow spec:AST-094 and design:reduced-motion, and the SYNC list and test header now point only at files in this pull request, but the diff is still over the 2,000-line limit.

  • At 2,984 authored lines, move coast.ts, coast.test.ts and the seven animator.test.ts tests that build curves with coast() into a pull request of their own, the split you offered.
  • Then say in the description, with a corrected size line (this change adds 2,954 lines in 10 files), why the remaining animator and its Reduce Motion substitution are one inseparable capability.

[Automated review]

* );
* ```
*/
export function coast(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

At 2,984 authored lines the diff is over the 2,000-line limit, and this module, with coast.test.ts and the seven animator.test.ts tests that build curves with coast(), is the separable cut you offered. Moving it into a pull request of its own leaves 2,608 lines, the animator and its Reduce Motion substitution, whose inseparability the description then needs to state, with a corrected size line.

@cixzhang

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf.

Cindy's standing waiver (2026-10-25) exempts new behavior modules from the line-count split. This PR introduces the compositor animator, a new behavior module, so the split request does not block it. Any other review findings still need to be fixed, and the normal checks apply.

@vjeux

vjeux commented Oct 26, 2026

Copy link
Copy Markdown
Contributor Author

The coast split is waived: under the standing waiver posted on #8056, new behavior modules are exempt from the line-count split, and this pull request was approved on that basis at 11:53Z. The description now says why the animator and its Reduce Motion substitution are one capability, and its size line reads 10 files, 2,954 lines added, 30 removed.

This branch was successfully deployed

1 active deployment
Preview — 8fc7f2ef 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