Skip to content

feat(interaction): animation-state waits for interaction proofs - #8505

Merged
cixzhang merged 55 commits into
mainfrom
test/harness-animation-waits
Oct 26, 2026
Merged

cixzhang merged 55 commits into
mainfrom
test/harness-animation-waits

Conversation

@cixzhang

@cixzhang cixzhang commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

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.

  • Recorder: sees every animation on the page from its first frame or its creation, so one that starts and ends before a test looks is still reported, with its finish time and its start (kept when the page cancels it).
  • 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 than playState, 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 self-tests in real Chromium (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.

vjeux added 30 commits October 24, 2026 14:37
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.
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.
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
cixzhang added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
@vjeux vjeux mentioned this pull request Oct 26, 2026
4 tasks done
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
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
cixzhang's meta#8076 head on main with meta#8505 (MERGEABLE). The uikit barrel takes
meta#8076's per-spring exports and keeps switchControl.
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
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
meta#8076's head folds meta#8505 (main 42a30b4 with meta#8446's harness line) and is
a fast-forward of the 2267ee5 this branch carries. It merges cleanly
with main f09d083, which the branch's previous base no longer did. No
conflicts; `timing.ts` is meta#8076's with exactly meta#8484's change.
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.
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
cixzhang added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
…eta#8505) into the press paint's Reduce Motion release
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
@github-actions
github-actions Bot deleted the test/harness-animation-waits branch October 27, 2026 06:22

This branch was successfully deployed

1 active deployment
Preview — c377245a 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.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants