Repository navigation
feat(interaction): animation-state waits for interaction proofs - #8505
Merged
Merged
Conversation
Adds internal/interaction-harness (@astryxdesign/interaction-harness) and playwright.interaction.config.ts: phone-sized touch projects in Chromium and WebKit for *.interaction.browser.spec.ts specs. Touch scripts are data (tap, drag with hold and rest, flick with an exact release velocity and velocity profiles, hold-then-drag, pinch, rotate, any path, a second finger mid-gesture), played as real engine input: CDP Input.dispatchTouchEvent with scripted timestamps in Chromium, WebKit's own input command in WebKit. The harness's self-test proves the timestamps, a native fling and multi-touch delivery; a pr-interaction CI job runs the proofs against each PR's Storybook build, and a container script runs the WebKit project where host libraries are missing.
… Chromium The Chromium driver sent a lift of one finger of several as a touchMove listing the fingers still down, assuming CDP releases a point a move omits. It does not: the lifted finger stayed down. A touchEnd that lists the lifting fingers releases exactly those while the others keep moving, so the driver sends that, and a self-test lifts one of two fingers and checks the release, the other finger's moves and the touchend's remaining touches. Playwright's WebKit sends that touchend with no remaining touches, which iOS never does. The harness does not work around it: drivers expose partialLifts, and PARTIAL_LIFT_SKIP is the reason a proof cites when it skips; the README lists it with the other engine limits.
… lift profiled flicks at their velocity - pr-interaction now always reports and the `test` join requires its success (AST-030 FR7, FR11), shaped like test-native-swift: a Linux interaction-scope job decides, with the base's scope helper and failing closed, whether the harness, a component, Storybook, internal/a11y-spec, the workflow, package.json or pnpm-lock.yaml changed. Only then do the proofs take a 4-core runner; otherwise, and in merge groups (as with pr-a11y), the job passes without work. - A profiled flick's last move runs at velocity times profile(1), so the finger lifts at the documented velocity, and releaseVelocity reads the last move instead of a 100 ms window mean. - Docs: drop the /math subpath this package does not export yet; scripted timestamps arrive to 0.1 ms; the job comment describes the self-test it runs.
…ed its previous position A sample that repeats a position is not a move: frames that merge() inserts for another finger, where this finger did not move, made the last move look shorter, so a 10 Hz drag merged with a tap read 375 px/s instead of 300 and a 60 Hz drag whose other finger lifted 5 ms before its last move read about 3.6x too fast. The previous sample is now the first one at the previous position, as the page sees it, with a merged case in scripts.test.ts.
Pause every Web Animation, seek it to a time and read the presented transform and opacity, then compare the curve with oracles that share no code with the motion under test: cubic-bezier, the SwiftUI spring (response, dampingFraction, initial velocity), UIScrollView deceleration and UIKit's rubber band. Storyboards photograph seeked animations at t = 0, 16.7, 33.3 ... ms or each step of a scripted touch, and write a PNG contact sheet plus the JSON trace a spec asserts, under the gitignored test-results/interaction-evidence. A timer gate holds a component's own timers while a filmstrip is taken. pnpm storyboards runs the tagged tests.
…sented values An animation that keeps its end value in a run of identical final keyframes (held so a later interruption can read it) reported its whole duration as the curve: storyboards filmed the hold, waitForAnimation's interval reached past the landing, and waitForRest, which also required every animation to leave the running state and read only transform, timed out. The curve now ends where that run starts (HeldAnimation.curveEndMs, waitForAnimation's to, the default storyboard times), and rest is no animation pending or before its curve's end plus the presented transform, translate, rotate, scale and opacity (and ::backdrop opacity) still for 3 frames, whatever the playState. sampleCurve composes translate, rotate and scale with transform, so curves of the individual properties are sampled.
…film no endless curve, let the page cancel gated callbacks - holdAnimations holds only running document-timeline animations: a finished fill animation keeps its end value, an animation the page paused stays paused (release resumes only what was held), and a scroll-driven one is not seeked. Each held animation keeps its offset from the earliest start, so staggered starts stay staggered. - A sample or storyboard frame at or past an end reports the time it was read at (presentedAt, half a millisecond inside the end), and curveOf reads oracles there. - An animation that repeats forever no longer sets a storyboard's default length (it ran the worker out of memory); with nothing else to film, animationStoryboard asks for times. - The timer gate queues held callbacks by id, so the page's clearTimeout and cancelAnimationFrame still cancel them; a held frame callback gets the time it runs; frames(), waitForRest and touchStoryboard use the real requestAnimationFrame, so they resolve while frozen. - The storyboard self-tests carry the @Storyboard tag that `pnpm storyboards` selects.
…s from the element's own animations, resume endless ones
- animationStoryboard's default times run from the earliest start to the
latest curve end among the animations that end, each seek at
origin + t: beside a spinner that started earlier the animation under
test no longer shows its start in every frame, and a second animation
that starts later is filmed to its end.
- A sample's presentedAt (per channel: transform, opacity) comes from the
sampled element's own animations, by keyframe property, not from any
animation on the page.
- releaseAnimations({finish: true}) resumes an animation that repeats
forever instead of finishing it (which throws), and always clears the
held set.
- The interaction proofs' scope includes packages/themes/*/src/.
…before it looked waitForAnimation polled getAnimations() after the action, so an entrance shorter than the first look (a cold page answers its first evaluate late) had already finished: "no animation started". The page now records every animation from the first call into curves.ts on it, and on every later document: a frame loop on its own requestAnimationFrame, the CSS animation and transition events, and Element.animate as it is called, pending animations included (WebKit can start and finish a short script animation between two frames of a fresh page). waitForAnimation also reports an animation that started after the last waitForRest or waitForAnimation and has finished, with finishedAt. watchAnimations(locator) arms the record before an action that may be the page's first contact with the harness.
…uest This pull request keeps the curve-sampling instruments: holding and seeking animations, sampling presented values, the oracles, waiting for an animation and for rest. The storyboards (storyboard.ts, the evidence output, the timer gate and the `pnpm storyboards` script) and their tests move to a pull request stacked on this one.
`holdAnimations` replaced the held set on each call, so a second hold left the first set paused after `releaseAnimations` (it resumed only the newest set), and `waitForRest` then reported the frozen frame as rest. A later hold now adds to the set: everything held stays held until the release, and offsets count from the earliest start of all of it. The self-test holds two elements in turn, releases, and checks the first runs to its end; on the old code it stays frozen 50 px into a 300 px move. The self-test's header and the `curves.ts` SYNC line no longer describe the storyboard and timer-gate layer, which moved to its own pull request.
Retargeting a CSS transition cancels it. If that happened while it was held, `seekAnimations` and `sampleCurve` set its time again, which revived it over the page's own new transition, and `releaseAnimations` replayed it from the start. Seeking, sampling and releasing now drop an `idle` animation from the held set first, and times count from a fixed origin (the earliest start held until the release), so dropping one never moves the others. The self-test retargets a held transition, seeks, releases and waits for rest: the cancelled transition stays idle and the box ends where the page sent it. On the old code the cancelled transition is still held after the seek.
`pause()` only schedules the pause: the engine takes the hold time at its next frame, so a held animation stayed held a frame past `heldAtMs`, and a read right after the hold varied by a frame between runs. Setting the current time right after `pause()` completes a pending pause at once, where `heldAtMs` read it. The self-test holds a 1 s move and checks it is still at `heldAtMs` two frames later; on the previous code it reads one frame later in Chromium (16.6–16.7 ms) and 15–70 ms later in WebKit.
# Conflicts: # package.json
Since main's root `pnpm typecheck` (#8390) runs every workspace package's own typecheck script, the separate "Typecheck interaction harness" steps in ci.yml and deploy.yml ran it a second time, and their comment ("the only gate over it") no longer held. Both steps go. The routing test that pinned them becomes one more directory in "keeps every typecheck gate that guarded main": internal/interaction-harness keeps the typecheck script that pnpm typecheck reaches.
The recorder's first look at the document came a frame after it started, so on a page the harness touched for the first time, watchAnimations armed before the recorder had seen an animation already running there (a spinner). That animation then read as started by the watched action: started() resolved with it with no action at all, or returned it instead of the animation the action started. The recorder now records once as it starts, before any watch arms. The self-test starts an endless spinner before the harness touches the page, then checks that the watch reports nothing until a 300 ms animation starts, and then only that one.
Held times count from an origin, the earliest start among the held animations. A second holdAnimations that took an animation which had started earlier than the first set moved that origin, so the offsetMs the first hold had returned went stale, and seeking or sampling its animations read them early. The origin is now the one the first hold set, until the release; a later hold's earlier-started animation gets a negative offsetMs. The self-test starts a 600 ms linear move 100 ms after another animation, holds the move first, then the other, and samples the move at 0, 150 and 300 ms: 0, 75 and 150 px. On the previous code it read 0, 33 and 108.
AST-030 FR9: removing a fail-closed edge must fail a test. The routing test only read the decide script's text, so turning its unknown default into "out of scope", or recording every helper failure as out of scope, or a guard step's `exit 1` into `exit 0`, left it green. It now runs the decide script under bash -eo pipefail with a stub git: a helper that answers in scope, out of scope or unknown, a base that cannot be fetched, a helper missing at the base, no base, a docsite-only change and a merge group, and checks the `applies` output of each. It also runs each guard step and checks that it fails. Each of those five mutations now fails it. flick's doc and the README said a velocity tracker reads exactly `velocity` for any profile ending at 1. That holds for the script's last move (what releaseVelocity reads); a tracker that blends recent intervals (INV10) reads it only when the profile is flat over them, as the default is.
…-curves # Conflicts: # .github/scripts/ci-interaction.test.mjs
Since a later hold keeps the first hold's origin, a later-held animation that started earlier has a negative offset, so the file header's "each keeping its offset from the earliest" and sampleCurve's "ms from the held animations' start" no longer described it. Both now name the held origin.
pr-interaction runs the proofs on the pull request's head. Once the proofs land on main, an in-scope pull request whose branch predates them has no playwright.interaction.config.ts, and Playwright would fail the required `test` check until it merged main. The scope decision now passes such a head with a notice: the head has no config, and the pull request's own diff does not change it (the base-loaded scope helper says so). A pull request that deletes the config changes it, so the proofs still run and fail; a head that cannot be fetched, an unknown helper answer or an unknown scope also runs them. The routing test runs the decide script with a stub git for each of those cases, and each mutation of the new edges (the head fetch, the HEAD_SHA guard, `touches=0`, `|| touches=$?`, the `= 1` comparison, the in-scope condition) fails it.
pr-interaction runs the proofs on the pull request's head. Once the proofs land on main, an in-scope pull request whose branch predates them has no playwright.interaction.config.ts, and Playwright would fail the required `test` check until it merged main. The scope decision now passes such a head with a notice: the head has no config, and the pull request's own diff does not change it (the base-loaded scope helper says so). A pull request that deletes the config changes it, so the proofs still run and fail; a head that cannot be fetched, an unknown helper answer or an unknown scope also runs them. The routing test runs the decide script with a stub git for each of those cases, and each mutation of the new edges (the head fetch, the HEAD_SHA guard, `touches=0`, `|| touches=$?`, the `= 1` comparison, the in-scope condition) fails it. CI-Rerun: main's gate test (fixed in #8406) failed this head's test-node
…ncels waitForAnimation and watchAnimations(…).started() built an animation's active interval from its startTime. A page that cancels a finished animation (an entrance whose class is removed on animationend) nulls that, so the entrance was reported as starting at the page's time origin, before its trigger. The recorder now keeps each animation's start time in its entry: from startTime once it resolves (read on every recorder pass), or, for one cancelled before any pass read it, back from where it finished. The interval falls back to it when startTime is null. The new self-test removes an entrance's class on animationend, 200 ms after the page loaded, and checks that the reported start follows the trigger. On the previous code it read about 250 ms before the trigger in Chromium and WebKit.
Nothing in this change runs internal/interaction-harness/scripts/ webkit-container.mjs: CI runs the Chromium project, and the script only runs the WebKit project locally inside the Playwright image on hosts whose system libraries are too old for Playwright's WebKit build. The script, its README paragraph and the config comment that names it move to the stacked change whose WebKit evidence first runs through it, which keeps this one under the 2,000-line limit for a pull request.
…ion-harness-1-touch
vjeux
added a commit
to vjeux/astryx
that referenced
this pull request
Oct 26, 2026
vjeux
added a commit
to vjeux/astryx
that referenced
this pull request
Oct 26, 2026
4 tasks done
vjeux
added a commit
to vjeux/astryx
that referenced
this pull request
Oct 26, 2026
This was referenced Oct 26, 2026
vjeux
added a commit
to vjeux/astryx
that referenced
this pull request
Oct 26, 2026
meta#8176 now rides a Tall BottomSheet on Gboard's slides in Chrome: the reveal lands the body at once and the content box rides up on the show's curve, and through the hide the room stays while the content box rides down by the scroll the room's drop takes back (the range the room adds, measured), both cleared in one commit at rest. It also takes meta#8076's 0400cb2 (meta#8505 with meta#8446's harness line). No conflicts; `timing.ts` is meta#8176's, with meta#8484's keyboard export.
4 tasks done
4 tasks done
4 tasks done
This was referenced Oct 26, 2026
vjeux
added a commit
to vjeux/astryx
that referenced
this pull request
Oct 26, 2026
This was referenced Oct 26, 2026
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The interaction harness's animation-state waits, split out of #8044 (curve sampling) so each pull request stays reviewable. The instruments are @vjeux's, from #8044.
waitForAnimation: the running animations on an element past a minimum progress, or the ones that finished since the last wait. Each comes with its curve interval through the playback rate; a run going backward is dated from when it began.watchAnimations: a watch taken before an action, for an entrance on a page's first contact.waitForRest: rest judged by the presented transform, translate, rotate, scale and opacity rather thanplayState, so an end value held by the keyframes is not waited out, and a backward run's end delay and curve are.Who is affected: authors of interaction proofs. Nothing ships in a package.
#8044 has landed, so this pull request holds only the waits (+792/−56 over main). The waits read a run reversed before it starts; one reversed or replayed mid-run reads as its first run (the README and JSDoc say so).
Test plan
harness.interaction.browser.spec.ts): 27 tests pass, 5x repeat. That includes the waits' cases: a running transition and rest after it, progress after a delay, a short animation that ended before the look, a watch on first contact, an entrance the page cancels when it ends, a backward run with a delay, one that ended before the look, one cancelled at its end, a finish read before its event, and rest through a backward end delay.pnpm vitest run --project node internal/interaction-harness: 25 pass; tsc and eslint clean.