Repository navigation
chore(core): a compositor animator for touch presentations - #8055
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
AI review status for this pull request.
|
PR Analysis Report📚 Storybook Preview🧪 Sandbox PreviewNo new or modified components detected. Bundle Size SummaryGzip figures measure each package's entry file, not the code an import pulls in.
Accessibility AuditStatus: No accessibility violations detected. Visual Regression55 of 502 shot(s) changed. A change here is a question, not a failure: check whether the after is the
and 35 more. Generated by PR Enrichment workflow | Storybook | Sandbox | View full report |
There was a problem hiding this comment.
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 resultssection that the knowledge-contracts architecture requires for a new record.
[Automated review]
| const opacityNow = valueAt('opacity', time); | ||
| const opacityTo = targets.opacity ?? opacityNow; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
mainrewrote the scroll-to-top note inpackages/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.tsis the cut you named, so move it withcoast.test.tsand thedecaytests that callcoast()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 |
There was a problem hiding this comment.
#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
There was a problem hiding this comment.
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.tswithcoast.test.tsand thedecaytests that build curves withcoast()into a pull request of their own, the split you offered; what remains is judged as one capability underspec:AST-094FR5, FR6 and FR8. - Minor:
packages/core/src/motion/uikit/scroll.ts:115now routes the tap-started scroll-to-top throughcoast.ts, againstdecay's momentum-only contract, andanimator.ts:81names 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 |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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 astopthat 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
mainand conflicts with this branch inpackages/core/src/motion/README.mdanduikit/uikit.test.ts, so mergemain, port the binding check into itsleavesOf, and keep its README wording. - Move
coast.ts,coast.test.tsand thedecaytests that build curves withcoast()into a pull request of their own, the split you offered, so the remaining animator can be judged as one capability underspec:AST-094FR5, FR6 and FR8.
[Automated review]
| borrowedOpacity = | ||
| borrowed ?? (targets.opacity == null ? previousBorrow : null); |
There was a problem hiding this comment.
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.
| // 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; |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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), wheredesign:reduced-motionDR2 cross-fades a transition started on release, so send that return throughanimateand add a coarse-pointer test. - At 2,846 authored lines, move
coast.ts,coast.test.tsand thedecaytests that build curves withcoast()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]
| 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; |
There was a problem hiding this comment.
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.
| * | ||
| * SYNC: When modified, update: | ||
| * - /packages/core/src/motion/animator.test.ts | ||
| * - /packages/core/src/motion/__tests__/animator.compositor.spec.ts |
There was a problem hiding this comment.
__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.
There was a problem hiding this comment.
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.tsand theanimator.test.tstests that build curves withcoast()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 todecayand that it plays only momentum, and point the SYNC list at a test file that exists.
[Automated review]
| /** | ||
| * 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 |
There was a problem hiding this comment.
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.
| * ``` | ||
| */ | ||
| /** | ||
| * The transition `decay` hands a Reduce Motion transition curve to: under |
There was a problem hiding this comment.
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.
…ross-fades under Reduce Motion
…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.
There was a problem hiding this comment.
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.tsand theanimator.test.tstests that build curves withcoast()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.tsarrives only with #8067.
[Automated review]
| * | ||
| * SYNC: When modified, update: | ||
| * - /packages/core/src/motion/animator.test.ts | ||
| * - /packages/core/src/motion/__tests__/animator.compositor.spec.ts |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.tsand the sevenanimator.test.tstests that build curves withcoast()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( |
There was a problem hiding this comment.
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.
|
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. |
|
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. |
#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-094FR5, FR6, FR8):coast()builds) into Web Animations keyframes ontranslate,rotate,scaleandopacity, played witheasing: linearon one explicit start time, so the browser runs them off the main thread.get/getVelocityread the curve with no layout read;animatestarts from the presentation value and velocity; a handle reportsfinished.reducedMotionCrossFade, not the timing table) on a coarse pointer and completes at once on any other (design:reduced-motionDR2–DR4); momentum after a throw keeps moving (DEC-2).Current specification
spec:AST-094FR5, FR6, FR8.Public delta and caller burden
None: internal and not exported; the changeset is
[chore].Design and alternatives
linear()easing: WebKit runslinear()on the main thread (iOS 26.2 simulator: 1 of 21 frames moved through a block, against 20 of 21 for baked keyframes).transform: a finger can drivetranslatewhilescalekeeps springing.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
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.pnpm check:changesets,pnpm check:repo(pre-commit): clean.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.