Skip to content

test(interaction): real touch input for browser interaction proofs - #8043

Merged
cixzhang merged 11 commits into
meta:mainfrom
vjeux:interaction-harness-1-touch
Oct 25, 2026
Merged

cixzhang merged 11 commits into
meta:mainfrom
vjeux:interaction-harness-1-touch

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Maintainer impact

Touch components cannot be tested with the input they ship for. Playwright's touchscreen.tap only taps, and dispatchEvent touches are untrusted: they never scroll natively or claim a gesture, and components can tell. Velocity-dependent behavior (a flick that dismisses, a release projected onto a detent) needs test-controlled timestamps, which no browser spec here has.

Intended invariant

No product code changes. A spec that claims an interaction drives it through the engine's own input pipeline, with positions and timestamps stated in the test, and a failing proof fails the required test check.

Change

  • internal/interaction-harness (private, never published):
    • scripts.ts: touch scripts as data: tap, drag, flick (it lifts at its scripted velocity, with or without a velocity profile), hold-then-drag, pinch, rotate, any path, a second finger; releaseVelocity (the last move's slope); toTouchEvents.
    • touch.ts: touchDriver(page).play(script). Chromium: CDP Input.dispatchTouchEvent with a timestamp per event; a partial lift releases only the lifted finger. WebKit: its own trusted input command; its limits are in the README, with PARTIAL_LIFT_SKIP for skips.
    • story.ts: the phone profile, a Storybook server per spec file, openStory.
  • playwright.interaction.config.ts: *.interaction.browser.spec.ts in Chromium and WebKit on a phone-sized touch screen; pnpm test:interaction.
  • CI, shaped like test-native-swift (AST-030 FR7, FR11): test requires pr-interaction. A Linux interaction-scope job decides with pr-scope-touches.sh, failing closed, whether the harness, its config, components, stories, internal/a11y-spec, the workflow, package.json or pnpm-lock.yaml changed; only then do the proofs run (Chromium, the PR head's Storybook build). Otherwise, and in merge groups as with pr-a11y, the job passes without work.
  • pr-interaction and interaction-scope take timeouts within main's 15-minute budget and join ci-duration's needs (ci: hold pull-request jobs to the 15-minute budget and report job durations #8427).

Stack: #8044 (curves), #8348 (storyboards), #8045 (frame evidence), #8423 (BottomSheet exemplar) follow.

Evidence

  • Failure before the change: no spec can drive a flick of known velocity or a second finger, and untrusted events do not scroll. With the job outside the join, a red proof left test green; the join and routing tests fail on that shape.
  • Success after the change: trusted touch and pointer events arrive at the scripted positions and times; a 2,400 px/s flick flings a native scroller 1.8× farther than an 800 px/s one; one of two fingers lifts alone. A profiled 1,500 px/s flick lifts at 1,500 px/s (its last move ran at 1,425 and releaseVelocity read 750).
  • Product/runtime behavior verified unchanged: no package source changes.

Scope

  • No intended public API, product behavior, visual direction, or policy change.
  • Product changes discovered during the work were removed or split.
  • Public text and artifacts contain no internal Meta context.

Testing

  • pnpm test:interaction --project chromium: 4 passed. WebKit 26.5 (in the official Playwright image): 2 passed, 2 skipped (no native fling from injected WebKit touches; no remaining touches on a partial lift).
  • pnpm exec vitest run .github/scripts internal/interaction-harness with main merged: 1,346 passed (ci-interaction, ci-test-join and ci-test-routing cases fail before the join change; ci-duration-guard failed three ways before the timeouts). internal/interaction-harness/src/scripts.test.ts: 11 passed (4 fail before the profile change).
  • pnpm build && pnpm typecheck, eslint, prettier and the pre-commit repo checks: clean.

@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 25, 2026 8:02pm UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Oct 25, 2026, 9:23 PM UTC

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 24, 2026
@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

No component packages changed.

Accessibility Audit

Status: No accessibility violations detected.

Visual Regression

48 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 556,900
RichTextEditor Markdown parity: side by side probe light 538,816
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 28 more.


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

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes requested: the touch harness works (its Chromium self-test passes on this head), but the new pr-interaction job runs as its own routed CI lane that no join requires, which docs/specs/AST-030/spec.md FR11 rules out for new coverage. Please fold the job into an existing owner, or drop it from this PR until an FR10 amendment admits it, and make the profiled-flick velocity match its documentation.

  • Require pr-interaction from an existing join (as test requires test-native-swift through ci-test-join.mjs) and add its own workflow file and pnpm-lock.yaml to its scope pattern, so edits to the job or a lockfile-only Playwright change still run the proofs.
  • With profile, flick never reaches the documented release velocity, and releaseVelocity reports the 100 ms window mean (750 px/s for a 1,500 px/s linear-profile flick); align the code or the docs and add a profiled case to scripts.test.ts.
  • Minor: src/index.ts names a /math subpath that does not exist yet, the job comment promises negative controls the self-test does not have, and the README's "to the microsecond" timestamps arrive in 0.1 ms steps.

[Automated review]

Comment thread .github/workflows/ci.yml Outdated
# them, so a change to an instrument is proven by its negative controls
# before a component spec relies on it. Chromium only on these runners;
# see the harness README for running WebKit.
pr-interaction:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

pr-interaction routes itself with its own path pattern and no join requires it, so a failing proof leaves test and build green. docs/specs/AST-030/spec.md FR11 asks new coverage to join an existing owner and not create a separately routed lane or required context: require this job from an existing join (as test requires test-native-swift through ci-test-join.mjs), or drop it until an FR10 amendment admits it.

Comment thread .github/workflows/ci.yml Outdated
if git fetch --quiet --no-tags --depth=1 origin "$BASE_SHA" \
&& git show "$BASE_SHA:.github/scripts/pr-scope-touches.sh" > "$helper"; then
scope=0
bash "$helper" "$BASE_SHA" '^(internal/interaction-harness/|playwright\.interaction\.config\.ts$|packages/[^/]+/src/|apps/storybook/)' || scope=$?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This pattern leaves out the job's own steps (this workflow file) and pnpm-lock.yaml, so a PR that edits only this job, or changes Playwright's resolution only in the lockfile, skips the proofs. native-swift-scope lists its own script in its pattern for the same reason; consider adding both paths here.

/**
* A throw: the finger moves with the given velocity profile and lifts while
* still moving (the lift lands a quarter frame after the last move, as on a
* device), so the release velocity is exactly `velocity`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

With profile, each segment is integrated at its midpoint (line 161), so the finger never reaches velocity: a 1,500 px/s flick over 80 ms at 120 Hz ends at 1,425 px/s with p => p and 1,353.7 px/s with p => p * p. Either run the last segment at full velocity or limit this promise to the constant profile, and add a profiled case to scripts.test.ts.

}

/**
* The velocity (px/s) of finger `id` at its lift: the slope over the last

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This returns the mean slope over the last 100 ms, not the lift velocity: for a 1,500 px/s linear-profile flick it reports 750 px/s (498.7 px/s for a quadratic profile). A spec that derives an expected detent from it would be off by half; report the final segment's slope or document it as a window mean.

* @input Re-exports the harness modules
* @output The `@astryxdesign/interaction-harness` entry point for Playwright
* specs. Pure helpers are also reachable without Playwright through
* `@astryxdesign/interaction-harness/math` and `/scripts` (unit tests).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@astryxdesign/interaction-harness/math is not in this package's exports and no such module exists at this head; drop the reference until the PR that adds it.

Comment thread internal/interaction-harness/README.md Outdated
Untrusted DOM events are not input: they start no native scroll, claim no
gesture, and read `isTrusted: false`. Chromium input goes through CDP
`Input.dispatchTouchEvent` with an explicit timestamp per event: the page's
`event.timeStamp` is the scripted time to the microsecond, whatever the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

In Chromium the page saw a scripted 8.571 ms spacing as 8.5 to 8.7 ms, in 0.1 ms steps, so "to the microsecond" overstates it; "to 0.1 ms" matches what the page receives.

This was referenced Oct 24, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 25, 2026
…nimator's React hook and browser proofs
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 25, 2026
…ainst main, meta#8043 landed) into meta#8270

touchPresentation.ts keeps this line's TOUCH_QUERY move to touchQueries.ts
over main's resolver, exactly as cixzhang resolved it on meta#8336 (eef0e64).
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 25, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 25, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 25, 2026
cixzhang added a commit to vjeux/astryx that referenced this pull request Oct 25, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 25, 2026
meta#8076's head carries current main with meta#8043's harness, the motion
foundation with AST-094 FR4's role names, and the CI duration guard's fix.

Conflicts, resolved:
- The harness, the gesture system, the motion tables, MEASUREMENTS.md, the
  motion README and the generated role kinds take meta#8076's side. The
  harness keeps this branch's trace option that leaves out Chrome's own
  compositor animation update (its README row and self-test, which
  ChatLayout's rendering proof uses).
- The keyboard's notification duration takes the motion foundation's way to
  keep it out of the theme roles: it stays `timing.keyboardNotificationMs`,
  which the timing table excludes from registration, instead of moving to
  `keyboard.notificationMs`. The keyboard model, the layout guide and their
  README read it there; a test still pins it, and the keyboard's spring, as
  no motion role.
- motion.doc.mjs and the touch-motion-theme-roles changeset take meta#8076's
  text, and keep this branch's sentence that ChatLayout's Reduce Motion
  cross-fade reads a role, now named `timing.curves.move`.

The motion role table regenerates unchanged from the merged tables (45
roles); the CLI docs golden that hashes motion.doc.mjs is regenerated.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 25, 2026
meta#8176's head now carries meta#8076's (current main with meta#8043's harness, the
motion foundation with AST-094 FR4's role names, and the CI duration
guard's fix). No conflicts.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
meta#8176's head now carries meta#8076's (current main with meta#8043's harness, the
motion foundation with AST-094 FR4's role names, and the CI duration
guard's fix).

Conflicts, resolved:
- keyboardModel.ts and its test: the keyboard's notification duration is
  `timing.keyboardNotificationMs` again (the motion foundation keeps it out
  of the theme roles), beside this branch's Android keyboard table, bézier
  and `profile` option.
- keyboard/README.md: keeps the Chromium section, and the constants line
  names both tables with the duration at `timing.keyboardNotificationMs`.
- motion/README.md: meta#8076's table, with the rows for the keyboard's two
  domain files (`uikit/keyboard.ts`, `android/keyboard.ts`).
cixzhang pushed a commit that referenced this pull request Oct 26, 2026
## Maintainer impact

Animation timing isn't verifiable today. Specs wait a fixed time and read one transform (the BottomSheet drag spec waits 40 ms, then checks > 5 px), so a curve can change shape, duration or starting velocity without a red test. Matching a measured reference curve needs the presented value at exact times and an oracle independent of the implementation.

## Intended invariant

No product change. A curve claim is checked against the presented value at fixed times, never against wall-clock frames.

## Change

- `curves.ts`:
  - Hold the running document-timeline Web Animations (CSS ones included), each at its offset from the earliest start, and seek them; a second hold adds to the held set, and one the page cancels leaves it.
  - Read the presented transform and opacity, with the time actually presented (`presentedAt`, from the sampled element's own animations, just inside an end).
  - Finished, page-paused and scroll-driven animations are left alone. Release resumes only what was held, and resumes an endless animation rather than finishing it.
  - `waitForAnimation`, `waitForRest`. The page records every animation from its first frame or its creation, so `waitForAnimation` also reports a short one that ended before the first look. `watchAnimations` arms the record before an action.
- `math.ts`: oracles that share no code with `packages/core`: `cubicBezier`, `timingFunction`, `springStep`, `projectDecay`, `rubberBand`, `parseTransform`, `compareCurve`.
- The proofs' scope includes `packages/themes/*/src/`.

Second of four stacked pull requests, on #8043 (touch input). Storyboards (#8348) and frame evidence (#8045) follow. Until #8043 merges, review this pull request's own commits.

## Evidence

- Failure before the change: no instrument reads a presented value at an exact time. Polling after the action misses a short entrance on a cold page ("waitForAnimation: no animation started on the element").
- Success after the change, in Chromium and WebKit:
  - A CSS transition matches its cubic-bezier within 0.05 px, including the sample at its end.
  - A spring baked into `linear()` matches the SwiftUI oracle within 0.15 px.
  - Staggered animations keep their offset, a sample is labelled from its own element's animations, and an endless animation resumes.
  - A 60 ms transition read 250 ms late, and a 50 ms script entrance on the page's first contact, are both reported.
- Product/runtime behavior verified unchanged: no package source changes.

## Scope

- [x] No intended public API, product behavior, visual direction, or policy change.
- [x] Product changes discovered during the work were removed or split.
- [x] Public text and artifacts contain no internal Meta context.

## Testing

- `pnpm test:interaction --project chromium` (harness): 18 passed.
- WebKit 26.5 through `node internal/interaction-harness/scripts/webkit-container.mjs`: 16 passed, 2 skipped (native fling; partial lift).
- `pnpm exec vitest run internal/interaction-harness`: 23 passed; `.github/scripts/ci-interaction.test.mjs` passes.
- Harness typecheck, eslint, prettier and the pre-commit repo checks: clean.
vjeux added a commit to vjeux/astryx that referenced this pull request Nov 3, 2026
vjeux pushed a commit to vjeux/astryx that referenced this pull request Nov 3, 2026
vjeux pushed a commit to vjeux/astryx that referenced this pull request Nov 3, 2026
Brings the harness as resolved against main (meta#8043 merged), so this stack
carries one copy of it. The only conflict, touchPresentation.ts, keeps
this pull request's TOUCH_QUERY move to touchQueries.ts over main's
resolver (meta#8062).
vjeux added a commit to vjeux/astryx that referenced this pull request Nov 3, 2026
…ainst main, meta#8043 landed) into meta#8270

touchPresentation.ts keeps this line's TOUCH_QUERY move to touchQueries.ts
over main's resolver, exactly as cixzhang resolved it on meta#8336 (eef0e64).
vjeux added a commit to vjeux/astryx that referenced this pull request Nov 3, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Nov 3, 2026
…nds down while a trace runs, and records animations that start and end inside it) into the animator's React hook and browser proofs
vjeux added a commit to vjeux/astryx that referenced this pull request Nov 3, 2026
…table

main's pr-interaction job checks out this branch's head and runs
playwright.interaction.config.ts, which meta#8043 added on main.
vjeux added a commit to vjeux/astryx that referenced this pull request Nov 3, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Nov 3, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Nov 3, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Nov 3, 2026
…nimator's React hook and browser proofs
vjeux added a commit to vjeux/astryx that referenced this pull request Nov 3, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Nov 3, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Nov 3, 2026
vjeux pushed a commit to vjeux/astryx that referenced this pull request Nov 3, 2026
cixzhang added a commit that referenced this pull request Nov 4, 2026
…ns (#8446)

* test(interaction): real touch input for browser interaction proofs

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.

* fix(interaction): release only the lifted finger on a partial lift in 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.

* fix(interaction): join the proofs to test, keep their setup in scope, 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.

* fix(interaction): releaseVelocity measures from when the finger reached 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.

* test(interaction): curve sampling and deterministic storyboards

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.

* fix(interaction): curves end before a held end value; rest judges presented 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.

* fix(interaction): hold only running animations at their own offsets, 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.

* fix(interaction): film from each animation's own offset, label samples 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/.

* fix(interaction): waitForAnimation sees a short animation that ended 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.

* refactor(interaction): the storyboard layer moves to its own pull request

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.

* test(interaction): a second hold adds to the held set

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

* test(interaction): deterministic storyboards

animationStoryboard films held animations by seeking them (t = 0, 16.7,
33.3 … ms, from the earliest start to the latest curve end among the
animations that end, each at its own offset); touchStoryboard takes a
frame per scripted touch event. Each writes a PNG contact sheet and a JSON
trace of its measurements to the evidence directory and attaches them to
the report; the spec asserts on the trace.

The timer gate (installTimerGate, freezeTimers, thawTimers) holds the
page's timers and frame callbacks while a storyboard photographs, so a
component that finishes an animation from a timer cannot do it behind the
held animation's back; the page can still cancel a held callback, and the
harness's own frame waits (and the animation recorder) keep the page's
real requestAnimationFrame. `pnpm storyboards` runs only the tests tagged
@storyboard.

* test(interaction): a touch filmstrip's handler clock follows its pacing

`touchStoryboard` said velocity-dependent behavior is unchanged in
Chromium. That holds for velocity read from event timestamps, which keep
their scripted times, but a handler that reads `performance.now()` or
`Date.now()` (Item's and Toast's swipes) sees one event per photograph,
about 100 ms apart, so a flick that dismisses when played can settle back
when filmed. The doc and the README now state that limit and point such
gestures at a screencast, and a self-test shows both clocks: scripted
16.7 ms gaps in `timeStamp`, more than two frames in the handler's clock.

* test(interaction): frame evidence and the BottomSheet exemplar

Timestamped composited frames (Chromium's screencast and WebKit's own),
a marker the frames can find, and the busy-main-thread proof: block the
page's main thread mid-flight and show the element still moving every
frame, with a requestAnimationFrame animation that freezes as the
negative control. Chromium trace counts of Layout, Paint and style work
inside an animation window, and long tasks. The exemplar spec proves the
BottomSheet drag to a detent with all of it: the drag follows the
finger, a flick dismisses, the settle matches its timing function, keeps
advancing through a blocked main thread in Chromium and WebKit, does no
Layout or Paint, and gets a drag and settle storyboard.

* test(interaction): make the busy-main-thread verdict robust to a loaded machine

The proof asserted a stall under 40 ms and near-exact frame counts, so a
dropped screencast frame or two on a loaded runner failed it, and frames
delivered out of order read as the element stepping backwards.
busyWindowProof now returns a verdict that tolerates what load does to a
screencast and nothing a frozen animation does: a median frame interval
of at most 20 ms, no stall longer than three screencast intervals, and
at least 90% of the window's whole frame intervals presenting a new
position (one miss always allowed), with the raw numbers in a summary.
Screencast frames are sorted by presentation time, the cadence comes
from the frames around the window, and blockMainThread can block from
inside the page in the frame an animation has run, so a short settle is
still judged at length on a busy machine. The exemplar judges its settle
until it is within 2 px of rest, since the screencast rounds to whole
pixels. A unit test covers the limits; the rAF negative control still
fails every criterion.

* test(BottomSheet): pin the settle's easing and duration instead of reading them from the animation

The settle-curve oracle took its easing and duration from the animation under
test, so it passed a 900 ms ease-in or a 150 ms linear settle as readily as
the real one. The spec now asserts the theme's values (the default
--ease-standard, cubic-bezier(0.24, 1, 0.4, 1), over the neutral theme's
--duration-medium, 300 ms) and builds the oracle from them; the header no
longer claims 410 ms.

* test(interaction): block after the frame's rendering update; rebase WebKit frames on its clock

- `blockMainThread`'s `during` path ran its loop inside the rAF callback of
  the frame that saw the animation. WebKit hands a newly started animation
  to its compositor in that frame's rendering update, which runs after rAF
  callbacks, so the loop held the hand-off and even a compositor animation
  froze (2 of 29 frames moved). The loop now runs from a task queued in that
  frame.
- WebKit's screencast frames were rebased onto the wall clock by taking the
  first frame as the start, which shifted every frame earlier by however
  long that frame took to come (60–100 ms). They are now rebased by the
  measured offset between the wall clock and CLOCK_MONOTONIC, which WebKit
  stamps them on.
- `busyWindowProof` fails a judged part shorter than four frames (the
  animation under test was not moving during the block) instead of passing
  it, takes the end value from `settledAt` when given (WebKit can present a
  stale position once an accelerated animation ends while the main thread
  is blocked), and reports where the longest stall starts.
- The BottomSheet busy case arms the block before the release and reads
  where the settle ends from the page.

* Merge branch 'main' into interaction-harness-2-curves

* Merge branch 'interaction-harness-2-curves' into uikit/f3-storyboards

* Merge branch 'uikit/f3-storyboards' into interaction-harness-3-frames

* test(interaction): a held animation the page cancels leaves the held set

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.

* Merge branch 'interaction-harness-2-curves' into uikit/f3-storyboards

# Conflicts:
#	internal/interaction-harness/src/harness.interaction.browser.spec.ts

* Merge branch 'uikit/f3-storyboards' into interaction-harness-3-frames

# Conflicts:
#	internal/interaction-harness/src/harness.interaction.browser.spec.ts

* docs(interaction): the touch filmstrip's clock limit names no missing instrument

`touchStoryboard`'s doc and the README pointed a clock-timed gesture at
`startScreencast` and `castStoryboard`, which this branch does not have. They
now say a storyboard cannot show such a gesture and to record it in real
time.

* Merge branch 'uikit/f3-storyboards' into interaction-harness-3-frames

* docs(interaction): the touch filmstrip's clock limit names the screencast instruments

With frame evidence in the harness, a clock-timed gesture a storyboard
cannot show is recorded with `startScreencast` and `castStoryboard`: the
doc and the README name them.

* test(interaction): a hold stays where it was read

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

* Merge branch 'interaction-harness-2-curves' into uikit/f3-storyboards

# Conflicts:
#	internal/interaction-harness/src/harness.interaction.browser.spec.ts

* Merge branch 'uikit/f3-storyboards' into interaction-harness-3-frames

# Conflicts:
#	internal/interaction-harness/src/harness.interaction.browser.spec.ts

* test(interaction): a block never starts on a pending animation

`blockMainThread({during})` counted an animation as running as soon as its
current time reached `minProgressMs`. An animation created in the frame the
page looks is still pending, not on the compositor yet, and at
`minProgressMs: 0` the block started on it and froze it: in WebKit
deterministically, and in Chromium on a Lightbox page while the block still
ran inside the frame's callback. A pending animation no longer counts, and
`minProgressMs` is raised to one frame (16 ms), since in WebKit an
animation's start time resolves before its layer is flushed.

The self-test creates the animation in the frame the helper looks and
blocks at `minProgressMs: 0`. On the previous code it fails 6/6 in WebKit
(0 frames presented through the block); now it passes 10/10 in WebKit and
6/6 in Chromium.

* test(interaction): the WebKit container keeps compiled shaders between runs

WebKit composites through Mesa's software GL in the Playwright image, which
compiles each shader variant the first time a page draws it. Mesa caches
what it compiles, but the cache lived inside the container, so every run
started cold: its first tests paid 550–680 ms for the first frame of a
blurred or newly animated layer, and a 683 ms animation could end before it
was ever presented or held.

The script now keeps the cache on the host (per image, under
~/.cache/astryx-webkit-shaders, or ASTRYX_WEBKIT_SHADER_CACHE) and points
MESA_SHADER_CACHE_DIR at it. The first frame of a blurred minimize in a
fresh container drops to 75–147 ms. The self-test checks, inside the
container, that the cache is the host's and that Mesa writes to it; with
the previous script it fails.

* Merge commit 'refs/f3/main' into interaction-harness-1-touch

# Conflicts:
#	package.json

* Merge branch 'interaction-harness-1-touch' into interaction-harness-2-curves

* Merge branch 'interaction-harness-2-curves' into uikit/f3-storyboards

# Conflicts:
#	package.json

* Merge branch 'uikit/f3-storyboards' into interaction-harness-3-frames

* ci: the harness's typecheck runs through pnpm typecheck, once

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.

* test(interaction): a watch on first contact ignores what already runs

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.

* Merge branch 'interaction-harness-1-touch' into interaction-harness-2-curves

* test(interaction): a later hold never moves the first hold's times

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.

* ci: pin the interaction routing's fail-closed edges by running them

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.

* Merge branch 'interaction-harness-1-touch' into interaction-harness-2-curves

# Conflicts:
#	.github/scripts/ci-interaction.test.mjs

* docs(interaction): seek and sample times count from the held origin

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.

* ci: a head cut before the interaction proofs passes with a notice

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.

* Merge branch 'interaction-harness-1-touch' into interaction-harness-2-curves

* ci: a head cut before the interaction proofs passes with a notice

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

* fix(interaction): a watch keeps the start of an animation the page cancels

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.

* chore(interaction): the WebKit container script moves up the stack

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.

* Merge branch 'interaction-harness-1-touch' into interaction-harness-2-curves

* chore(interaction): the BottomSheet detent exemplar leaves the harness change

The exemplar spec pins BottomSheet outcomes that the component's contract
does not state yet, which is a decision for BottomSheet's owner, and it
took this change past the 2,000-line limit for a pull request. It moves,
with the core package's development dependency on the harness and its
README and config mentions, to a pull request of its own stacked on this
one, so this change is the frame-evidence instruments alone.

* fix(interaction): a storyboard retakes a photograph Chromium returns empty

Chromium photographs a page by copying its compositor surface, and answers
"Unable to capture screenshot" when that copy comes back empty: right after
the page's surface changes (a navigation, a resize) the view can have no
surface to copy. Chromium retries five times itself, but without waiting
for a new frame, so a gap longer than a few tasks exhausts them. One CI
run failed the storyboard self-test that way on its first photograph,
about 100 ms after the page loaded.

Storyboard.capture now takes such a photograph again once the page has
presented two more frames, up to three attempts in all, and lists each
retake under `recaptures` in the trace. Any other error, or a third empty
copy, still throws.

The new self-test makes the page's screenshot fail once with Chromium's
message: the previous code threw, in Chromium and WebKit; now the second
capture comes at least two frames later and the trace records it, and
captures that keep failing still throw.

* test(interaction): pin what the long-task log sees; the cache test watches its own run

The long-task log reads the Long Tasks API, which reports the tasks of the
page's own event loop. A DevTools page.evaluate, such as blockMainThread
without `during`, is not one of them: a 300 ms block of that kind left the
log empty while the trace counted a 300 ms task. installLongTaskLog now
says so, and a self-test pins both sides: a 120 ms timer task is in the log,
and the 300 ms evaluate is in the trace's long tasks but not in the log.
Without the log installed, the test fails.

The shader-cache test passed from a warm cache even if Mesa stopped writing
there. It now launches its own browser with an empty directory inside the
host-kept mount and expects this run's shaders to appear in it; with Mesa's
cache disabled it fails.

* docs(interaction): the storyboard retake waits two frames, three attempts in all

The recaptures doc and the self-test's title said a retake comes "a frame later", and the README said "up to three times"; capture waits two frames and makes at most three attempts in all. The wording now says so.

* test(interaction): the self-test films a cast window

castStoryboard was the one instrument the self-test did not exercise. The
new test records a screencast of a running animation as JPEG (WebKit's
frames always are), films a window of its middle frames zeroed on an
earlier one, and checks that the filmstrip keeps exactly the presented
frames inside the window, labels each from the zero, hands the data
callback each frame's presentation time, and stores every cell as a PNG;
a window with no presented frame in it films nothing, and writing it fails.
Dropping the window filter, labelling from the raw time, keeping the JPEG
bytes, or passing the zeroed time to the callback each fails it.

* Merge branch 'interaction-harness-2-curves' into uikit/f3-storyboards

# Conflicts:
#	internal/interaction-harness/README.md
#	internal/interaction-harness/src/harness.interaction.browser.spec.ts

* chore(interaction): the WebKit container script lands with the storyboards

internal/interaction-harness/scripts/webkit-container.mjs left the
touch-input change to keep it under the size limit, since nothing there
runs it. It returns here unchanged, with its README paragraph and the
config comment that names it: the storyboard evidence is the first that
runs the WebKit project through it on hosts whose system libraries are too
old for Playwright's WebKit build.

* Merge branch 'uikit/f3-storyboards' into interaction-harness-3-frames

# Conflicts:
#	internal/interaction-harness/README.md
#	internal/interaction-harness/src/harness.interaction.browser.spec.ts

* Merge remote-tracking branch 'refs/remotes/origin/main' into interaction-harness-1-touch

* ci: the interaction jobs keep to the 15-minute budget and report their durations

Since #8427 every job that can run on a pull request carries a
`timeout-minutes` of at most 15, and `ci-duration` waits on every one of
them to report run times. `pr-interaction` gets the budget's 15 minutes,
like the other proof jobs, and `interaction-scope` 5, like the other jobs
that only classify; both join `ci-duration`'s needs.
`ci-duration-guard.test.mjs` failed three ways on this branch merged with
main (no timeout on either job, 26 jobs expected in the report against 24
listed) and passes now.

* Merge branch 'interaction-harness-1-touch' into interaction-harness-2-curves

* Merge branch 'interaction-harness-2-curves' into uikit/f3-storyboards

* Merge branch 'uikit/f3-storyboards' into interaction-harness-3-frames

* fix(interaction): the animation recorder stands down while a trace runs

The animation recorder runs a callback every frame, which makes every frame
a main-thread frame: Blink then restyles each running animation's target,
a composited one included, and lays out an anchored one, and each of its
reads of document.getAnimations() flushes style. A rendering trace counts
exactly that work, so a "no Layout" window counted one Layout per frame
that the page under test never caused.

traceRendering now sets `__harnessRenderingTrace` before its start mark.
While it is set, the recorder runs no frame callback and does not read the
page's animations on CSS animation events; its animate() wrapper still
records script animations, reading no style. After the end mark,
traceRendering clears the flag and calls `__harnessAnimations.resume()`,
which catches up on what started meanwhile, outside the counted window,
and runs the recorder's frames again. A recorder that starts during a trace
still reads once, at its start, so what already runs is never taken for
something the watched action started.

Two self-tests. With a trace flagged, a CSS transition and a script
animation start: the recorder reads nothing (the previous one read 16-17
times in Chromium and 11-12 in WebKit in the same 200 ms), reads every
frame again once resumed, and the watch reports both. And an anchored
element's composited animation, traced with the recorder running, lays out
0 times in its steady middle, where the previous code counted 24; a page
that asks for a frame every frame still lays it out in each.

Found and patched by the menus lane; adapted to the recorder's current
first-contact read.

* fix(interaction): a trace still records CSS animations that start and end inside it

While a trace ran, the recorder skipped its reads on CSS animation and
transition events, and its catch-up read after the trace sees only what
is still running, so a CSS transition or animation that started and ended
inside the trace was never recorded: a watch taken before the trace threw
"no animation started". During a trace, each such event now reads the
animations of the element it names, once, at that animation's start (its
subtree for a pseudo-element), with no frame callback and no document-wide
read.

The trace self-test on the anchored page runs a 400 ms CSS transition and
CSS animation inside traceRendering: both are reported with the time they
finished, and the steady middle still counts no Layout. The flag self-test
now starts a CSS transition and a CSS animation that end inside the
flagged window, beside a script animation: no document-wide read, at most
one element read per start event, and all three reported after the
resume. On the previous commit both tests failed, in Chromium and (for the
flag test) WebKit.

* Merge main (#8043 landed) into the curve sampling

* Merge branch 'uikit/f3-storyboards' into interaction-harness-3-frames

* Merge branch 'interaction-harness-3-frames' into uikit/f3-recorder-standdown

* Merge branch 'interaction-harness-2-curves' into uikit/f3-storyboards

* fix(interaction): curve samples stay true for percentage translates, pseudo-elements and delayed transitions

- sampleCurve resolves a percentage translate against the unrounded border
  box (offsetWidth rounds it) and refuses what it cannot resolve (a calc())
  instead of reading NaN.
- A ::before or ::after animation no longer counts as its element's own, so
  it cannot label the element's samples with its clock.
- waitForAnimation's minProgressMs counts progress after a transition's
  delay.

Each has a Chromium self-test.

* fix(interaction): compareCurve fails a point it cannot compare

A sample the reference has no value for, a non-finite sampled value or an
oracle with a NaN parameter now reads as an infinite error and fails, instead
of being skipped and passing with maxError 0.

* fix(interaction): the busy-main-thread verdict judges the frames the screencast delivered

busyWindowProof failed any window whose screencast frames came more than
20 ms apart. That measures the screencast as much as the animation: a loaded
machine's screencast delivers one frame in five or six, and a compositor
animation that moved on every one of them failed (660 ms judged at 100 ms per
frame, 6 of 6 moved, median 100 ms > 20 ms). A frozen main-thread animation
presents no frame at all during a block, so the stall and moved-frame
criteria, both counted in the screencast's own intervals, fail it at any rate.

The default verdict now requires a presented frame in the judged part and
leaves the median out; maxMedianFrameMs is an opt-in for a proof that claims
display rate. startScreencast takes everyNthFrame, and the self-test runs the
compositor and rAF animations at one frame in six too.

* Merge branch 'interaction-harness-3-frames' into uikit/f3-recorder-standdown

* Merge main (#8462 curve oracles and #8053 landed) into the curve sampling

# Conflicts:
#	internal/interaction-harness/src/index.ts

* Merge remote-tracking branch 'refs/remotes/fork/interaction-harness-2-curves' into uikit/f3-storyboards

# Conflicts:
#	internal/interaction-harness/README.md

* Merge branch 'uikit/f3-storyboards' into interaction-harness-3-frames

* Merge branch 'interaction-harness-3-frames' into uikit/f3-recorder-standdown

* fix(interaction): curve ends under a non-linear effect easing, and rotate about any axis

- curveEndMs and waitForAnimation's end read a held end value only under a
  linear effect easing (a keyframe offset is progress, not time); otherwise
  the curve runs to the full end.
- sampleCurve composes rotate on its parsed axis (x, y, z or a vector), and
  refuses what it cannot parse, as translate does.
- The calc() self-test finishes the first animation before starting the next,
  so no frame passes between them.

Each has a Chromium self-test.

* Merge remote-tracking branch 'refs/remotes/fork/interaction-harness-2-curves' into uikit/f3-storyboards

* Merge branch 'uikit/f3-storyboards' into interaction-harness-3-frames

* Merge branch 'interaction-harness-3-frames' into uikit/f3-recorder-standdown

* fix(interaction): waitForRest stands down while a trace runs, as the recorder does

A waitForRest awaited inside a traceRendering body judged rest from a
per-frame callback, which made every frame a main-thread frame again: 24
Layouts and 24 style recalculations in the anchored fixture's steady middle,
against 0 with a plain timeout.

While a trace is flagged it now looks at the element's animations once and
waits on a timer for the latest scheduled curve end, reading no style; one
not started yet is looked at a frame later, and a trace that ends hands over
to the per-frame check. Its self-test sits beside the recorder's: 0 Layouts
with the wait in the body, and the body still lasts the curve.

* fix(interaction): a held end is solved through a cubic-bézier easing, and only for a forward run

curveEndMs (and so waitForAnimation and waitForRest) reads a held end only
for an animation that plays forward: reversed or alternating, the held run is
where its motion starts, so the curve runs to the full end. Under a linear or
cubic-bézier effect easing (the CSS keywords included) the time the held run
starts is solved from the curve; under any other easing the curve runs to the
full end, and the README and JSDoc say waitForRest then waits the hold out.
Self-tests cover the solved end and the reversed run.

* fix(interaction): judge a just-started animation from when it reaches the screen; busy self-tests resolvable on WebKit's 2 px grid

Two WebKit flakes in the busy-main-thread self-tests, both from what a
loaded machine shifts, fixed without changing a limit:

- A block 16 ms into a transition opened with a stall in every run: the
  transition reached the screen 50-100 ms into the block (WebKit's compositor
  hand-off), 2 intervals unloaded and 4 under load, which failed. When the cast
  saw the element at rest before the block, judging now starts at its first
  presented change, still ending with the block and still needing four
  intervals; onScreenFromMs and the summary report it. An element already
  moving, or one no frame showed at rest, is judged from the block's start.
- WebKit's casts are captured at half resolution: positions move in 2 CSS px
  steps, and the self-tests moved about 2.4 px a frame, so a frame delivered a
  few ms early showed no step and read as not moved. The busy self-tests now
  move 560 px in 1,600 ms on their own page (about 4.8 px a frame), and the
  proof reports stepPx and pxPerFrame and notes motion under two steps per
  frame (never a failure).

Screencast.frameCount() lets a proof wait for the cast's first frame.

* Merge commit 'e2da8b9eba' into uikit/f3-recorder-standdown

* fix(interaction): an animation reversed by reverse() reads to its full end

A negative playback rate runs the keyframes backward like a reverse
direction: its held run is where the motion starts, so curveEndMs (and
waitForRest and waitForAnimation) read to the full end. Self-test added.

* fix(interaction): a reverse()d animation is reported from its real start and refused by a hold

Chromium gives an animation running backward a startTime where it would
reach 0 (a full duration in the future), so waitForAnimation reported it a
duration late. It now dates such an animation from when its current time
stood at its end. holdAnimations refuses it with a clear error: seeking and
sampling run a curve forward, so it cannot be read. The self-test awaits the
animation's ready promise (reverse() is pending until then), checks the
reported interval, and expects the refusal; the README says so.

* fix(interaction): a traced waitForRest waits on the real timers, which a frozen gate cannot hold

Inside a traced body waitForRest scheduled its wait with the page's
setTimeout, which installTimerGate replaces and holds while freezeTimers is
on: with the gate frozen it neither resolved nor reached its deadline until
thawed. The gate now keeps its real setTimeout as `timeout` beside its real
`frame`, and the traced wait uses it. Its self-test, beside the anchored
one, freezes the gate around a traced wait: "held" on the previous code,
resting now.

* fix(interaction): a held run counts only when it reaches offset 1 and keys every property

A property keyed only before the held run, or a last keyframe short of
offset 1, gets an implicit end frame, so the element keeps changing after
the run starts; curveEndMs now reads such an animation to its full end, with
a self-test for each case. The reverse() self-test takes its watch before
the animation starts, so it reads that animation and not earlier ones. The
README lists math.ts once.

* fix(interaction): the busy verdict reads the compositor's own frames where the screencast skipped them

Under parallel load Chromium's screencast skipped frames at fixed points of a
block (about +365 and +465 ms) while the compositor kept presenting every
16.7 ms and the marker kept moving, so the verdict counted the skipped frames
as not moving and measured a 66.9 ms stall: 4 of 19 recorded runs failed.

startCompositorTrace records when the compositor presented each frame
(Display::FrameDisplayed, Chromium; [] elsewhere). Passed as `presented`, a
gap between two moved frames with nothing delivered inside it is measured
between the compositor's own frames: frames it presented there count as
presented (recorderSkipped, in the summary), and a gap it did not present
through is a stall as before. Gaps at the judged part's edges, or holding a
delivered frame that did not move, are judged from the screencast. No limit
changes.

A recorded run is replayed as a unit test (fails without the record, passes
with it), beside controls the record never excuses: a frozen element while
the compositor draws, a compositor stall, an unmoved delivered frame inside
a gap, repeated real drops, an edge gap, an empty record. In Chromium the
self-test skips four screencast frames mid-block (passes with the record),
and its rAF control still fails with it: the compositor presents nothing
during that block.

* Merge commit '7c8ab74748' into uikit/f3-recorder-standdown

* fix(interaction): a traced waitForRest looks for a few frames before settling on no animation

Inside a trace, waitForRest settled on its first look when the element had
no animation yet, where the per-frame check needs `frames` still frames: an
animation the action started two frames later began 8-16 ms after the
traced window had closed, so a "no Layout / Paint" count read 0 for motion
the window never held. Finding nothing, the traced wait now looks again
`frames` times a frame apart (on the gate's real timer) before settling. Its
self-test starts the animation two frames after the action and checks the
traced window holds all of it.

* test(interaction): held offsets against the runners' own start times

The holding self-test asserted the second runner's held offset was over 80 ms because it started 100 ms after the first. A start time resolves at the animation's first committed frame, so on a loaded machine the first runner's start landed late and the offset read 75-78 ms. It now reads both start times from the page and asserts the held offset equals their difference; the first runner's first frame is held back 60 ms, as load does, so the old assertion fails every run.

* fix(interaction): a reversed run's progress counts from its end; waits, seeks and samples go through the playback rate

The review's items on 2efaf04232:

- waitForAnimation counted a run's progress as currentTime minus its delay,
  which a run reversed by reverse() meets in the frame it starts (its current
  time starts at its end), so a wait could return before Chromium lists the
  run and the hold resolved [] instead of refusing it. A backward run now
  progresses from its end, and progress is measured on the timeline.
- At a playback rate other than 1, waitForAnimation's from and to, and
  seekAnimations' and sampleCurve's local times, read the effect clock: a
  1,000 ms curve at rate 2 reported a 1,000 ms interval and a seek to t
  presented the curve at t. They now go through the rate (a hold still
  refuses a backward run).

A self-test pins each: a reversed run's 100 ms wait returns once it has
presented 100 ms (16.7 ms in Chromium and 84 ms in WebKit before), and at
rate 2 the interval is 500 ms and a seek or sample at t reads the curve at 2t.

* fix(interaction): the busy verdict counts the frames a screencast delivered, not its median interval

Without a compositor record (every WebKit run), the moved criterion counted
the frames the judged part should hold at the median interval of the frames
around the block. WebKit's casts mix short and long intervals, so the median
lands on either: the same 46 moved frames were judged against 40 to 50
expected frames across six runs, and a cast cycling 11.7, 11.7 and 17 ms with
every frame moving failed at 49 of 56.

The criterion now counts the frames the screencast delivered in the judged
part: a delivered frame that shows no new position is a miss, and a frame it
did not deliver is a gap, judged by the stall check. With a compositor record
it still counts the compositor's frames, and a part that delivered nothing is
still judged against its span, so a frozen animation fails every criterion.
deliveredFrames reports the count. No limit changes.

* Merge commit 'a2613849a20b17740a8b786ccdaa1ae282f12177' into uikit/f3-recorder-standdown

* fix(interaction): a run going backward is dated from when it began

Chromium gives a run going backward a startTime where its current time would
reach 0, a whole run after it began. waitForAnimation and watch.started()
now anchor it at startTime minus endTime over the speed, in describe and in
the recorder's saved start (so one the page cancels when it ends keeps its
real start), and take from/to from the end delay and endTime minus the
delay, which play first and last going backward. A self-test covers a
delayed reverse(), one that ended before the look, and one cancelled when it
ends; it failed by 296 ms before (Chromium, 27/27 and 5x repeat pass).

* fix(interaction): a finish read before its event goes through the rate, and a backward end delay is not progress

waitForAnimation's fallback finish time (read before the finish event is
delivered, right after finish()) now goes through the playback rate's speed,
and for a run going backward is its startTime; it was a whole run late for a
reverse()d run and 500 ms late at rate 2. minProgressMs no longer counts a
backward run's end delay, which plays first and presents nothing. Self-tests
for both; the finish case failed by 1,000 ms before (Chromium, 27/27 and 5x
repeat pass).

* fix(interaction): a traced waitForRest waits for a run going backward until it finishes

While a rendering trace runs, waitForRest waits on timers for each curve's
time left, computed as its end less its current time. Going backward
(reverse(), a negative playback rate) the current time falls from the end
time to 0, so that difference is the time already played: a reversed move
with a 200 ms end delay settled within 50 ms, before it began to move, and
one without waited about twice its length.

Backward, the time left is now the current time over the rate's magnitude:
the run is waited for until it finishes, as the untraced path waits it out.
A self-test pins both: the delayed reversed move holds the traced wait for
its 800 ms, and a reversed move is waited for no longer than the same move
forward.

* fix(interaction): waitForRest waits out a backward run's end delay and curve

A run going backward plays its end delay first, then its curve down to the
start delay; waitForRest counted it as done once its current time passed
curveEnd, so it resolved inside the end delay (47 ms into a 600 ms run with a
500 ms end delay) and the element then moved. It now waits until the current
time reaches the start delay. Self-test added (Chromium 27/27, 5x repeat).

* fix(interaction): a rendering trace reports DevTools-run tasks apart from the page's long tasks

traceRendering's longTasks counted every main-thread task over 50 ms,
including tasks that ran a DevTools command. A harness read inside the body
(waitForAnimation describing every animation's keyframes: 232 ms for 1,200
animated rows) was reported as the page's long task and failed proofs that
the page has none.

A task that ran a DevTools command is recognized in the trace by the
DevTools session's message that opens it. Such a task is now reported in
devtoolsTasks, unless page script ran inside it (an event listener, timer
or frame callback the evaluate set off), which keeps it a long task: an
evaluate that clicks a button whose handler spins 60 ms is still the
page's. blockMainThread(page, ms) without during is reported in
devtoolsTasks; with during it stays a page task, as the Long Tasks log sees
it. No limit or rendering count changes.

* Merge commit '1e242bed3ff02263f58be1488898c799b8cd6957' into uikit/f3-recorder-standdown

# Conflicts:
#	internal/interaction-harness/README.md
#	internal/interaction-harness/src/harness.interaction.browser.spec.ts

* refactor(interaction): the animation-state waits move to their own pull request

This pull request keeps curve sampling (hold, seek, sample, release, the
curve end); the recorder, waitForAnimation, watchAnimations and waitForRest,
with their self-tests, land separately on top. The holding self-tests that
waited with them now poll the page for a running or idle animation. Chromium:
19 tests, 5x repeat, pass.

* feat(interaction): animation-state waits for interaction proofs

The harness's animation-state instruments, split out of the curve-sampling
pull request so each stays reviewable:

- a page-side recorder that sees every animation from its first frame or
  creation, so one that starts and ends before a test looks is still
  reported, with its finish time and 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, with their 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 presented transform, translate, rotate, scale
  and opacity, not playState, so an end value held by the keyframes is not
  waited out, and a backward run's end delay and curve are.

Self-tests cover each in real Chromium.

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>

* docs(interaction): the waits read a run reversed before it starts; a mid-run reverse or replay reads as its first run

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>

* Merge main (#8044 landed) into the animation-state waits

# Conflicts:
#	internal/interaction-harness/README.md
#	internal/interaction-harness/src/curves.ts
#	internal/interaction-harness/src/harness.interaction.browser.spec.ts

* Merge #8505 (e8b0bb62: main with #8044, plus the animation-state waits) into the storyboards

# Conflicts:
#	internal/interaction-harness/README.md

* Merge #8348 (main with #8044 and #8505's waits) into the frame evidence

* Merge #8045 (main with #8044 and #8505's waits) into the recorder stand-down

# Conflicts:
#	internal/interaction-harness/src/curves.ts

* fix(interaction): openStory turns Storybook's highlight module off

Storybook 10's highlight module observes #storybook-root and, on every
insertion, reads the computed style of every element on the page inside the
inserting task: one exit copy in a Button story carried 117 getComputedStyle
calls and two forced style recalculations, which a proof then counted as the
component's.

openStory sets the module's own __STORYBOOK_HIGHLIGHT_INITIALIZED before the
preview runs, so the module returns at once, and checks that its root element
is absent once the story renders (a Storybook that stops reading the flag
fails loudly). openStory(…, {storybookHighlight: true}) keeps it on, in a page
that has not turned it off. A self-test on a built story counts the reads and
style recalculations an insertion carries: none by default, every element's
with the module kept on.

* fix(interaction): a trace sets apart only the harness's own reads, never a test's evaluate

traceRendering filed every DevTools-run task that set off no page script in
devtoolsTasks. A test's own page.evaluate that calls page code directly
(a story's API, a hook) leaves no listener or timer in the trace, so its
work was filed there too, and a "no long task" proof passed over it.

The harness now marks its own page-side reads (console.timeStamp with
HARNESS_READ) in waitForAnimation and in blockMainThread without during.
devtoolsTasks holds only tasks that carry that mark and ran no page script;
every other long task, a test's evaluate included, is the page's. The
self-test adds a 60 ms page function a test's evaluate calls directly: it is
a long task, and without the mark in waitForAnimation its read is one too.

* fix(interaction): a wait keeps waiting while the action's animation is pending

waitForAnimation and watch.started() left pending animations (no start time
yet) out of the running set, so an earlier animation that had finished
answered for the one the action started. A pending animation in scope now
keeps the wait going. Self-test: a short animation ends, then a longer one
starts; the wait returns the longer one (it returned the 40 ms one before).
The README and JSDoc say how a mid-run reverse or replay is misread.

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>

* test(interaction): openStory's highlight self-test runs in both engines

Storybook's highlight module scans the page on every insertion in WebKit as in Chromium, so the self-test now counts the insertion's getComputedStyle calls in both engines; only Chromium's trace counts the style recalculations they force. It no longer serves Storybook for an engine it skips.

* fix(interaction): a traced waitForRest skips scroll- and view-timeline animations

Inside a trace, waitForRest waits on timers for each animation's scheduled
end. A scroll- or view-timeline animation (DateInput's wheel rows) has none:
its time is a percentage, the wait came out NaN, and the timer re-armed with
no delay until the deadline, where the untraced wait rests. It now skips
animations that are not on the document timeline, as holdAnimations does.
Self-test: a scroll-timeline row inside a trace; it rejected at the deadline
before (Chromium, 3x repeat pass).

* Merge #8505 (9320644717: a wait keeps waiting while the action's animation is pending) into the frame evidence

* fix(interaction): the busy verdict judges a sparse cast from its first moved frame, and the harness's waits run on the real timers

With a compositor record, the moved criterion counted every compositor
frame in the judged part, but only a gap between two moved frames can be
credited to the compositor, so the frames before the first and after the
last moved frame read as misses: at one screencast frame in six, a
composited animation failed in 5 of 7 runs, where the same frames passed
without the record. The criterion now counts the compositor's frames from
the first to the last moved frame; the gaps before and after are the stall
check's, which already measures the window's edges.

blockMainThread(..., {during}) and sampleRects waited on the page's own
requestAnimationFrame and setTimeout, which freezeTimers holds, so neither
settled under a frozen timer gate, though the harness's own waits are
documented as not held. They now run on the gate's real frame and timeout
(the gate exposes its real setTimeout too). A unit case at one frame in six
and a self-test under a frozen gate fail on the previous code.

* fix(interaction): a scroll- or view-timeline animation does not hold up a wait

waitForAnimation and watch.started() counted a scroll- or view-timeline
animation (DateInput's wheel rows) as running; it never progresses on its
own, so the wait never answered with the animation that finished and
rejected at its timeout. Only document-timeline animations count now, as for
holdAnimations. Self-test: a scroll-timeline row beside a short animation
that ends; it rejected before (Chromium, 3x repeat pass). The installCurveEnd
doc comment sits above installCurveEnd again.

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>

* test(interaction): the long-task self-test judges the harness read, not the page's frames around it

"a harness read inside a trace is not the page's long task…" also asserted
that no long task started anywhere in the read's window. On CI's runner each
frame of the fixture's 1,200 running rows took 51-54 ms, the page's own long
tasks, and failed it; at 4x CPU locally they take 126-220 ms. The read
itself was set apart correctly.

The test now asserts the read's own classification, and removes the rows
before the timer, click and direct-call windows, so each holds only its own
work. It passes at 4x CPU, where the previous assertion failed.

* Merge #8505 (462d82caaf: a scroll- or view-timeline animation does not hold up a wait) into the frame evidence

* Merge branch 'interaction-harness-3-frames' into uikit/f3-recorder-standdown

* fix(interaction): a scroll-driven animation at its end is not a finished curve

waitForAnimation's finished branch read every recorded animation, so a
scroll- or view-timeline animation that reached its end (a scroller scrolled
to its end) came back as finished with a NaN duration and interval. The
recorded set now keeps document-timeline animations only. The scroll-timeline
self-test scrolls its scroller to the end first; it returned the NaN entry
before (Chromium, 3x repeat pass). The README, JSDoc and file header name
watchAnimations where they list what moves the wait window and what the file
exports.

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>

* Merge #8505 (a9be409b35: a scroll-driven animation at its end is not a finished curve) into the recorder stand-down

* fix(interaction): a watch stamps after the newest entry, and an exit removed at its end is still reported

- Arming the recorder now records first and stamps strictly after the
  newest entry: a watch taken right after a step that changed style no
  longer reports that step's transition (created by the arm's own style
  read) as started after it.
- Each entry keeps its target's ancestors as recorded, so an exit whose
  element the page removes at its end is still reported for the container
  it left, instead of a wait rejecting with nothing recorded.

A self-test for each; both failed before (Chromium, 3x repeat pass).

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>

* fix(interaction): after an on-screen start, the busy verdict judges the frames it credits as skipped

With a compositor record and an on-screen start, the moved criterion counted
from the first moved frame after the on-screen frame, while the gap from the
on-screen frame to it was credited as frames the screencast skipped. When the
cast skipped the frames right after the on-screen start, those credits had
no place in the denominator: four unmoved frames that fail on a full cast
("31 of 35 expected frames moved") passed as "26/30 moved and 5 skipped by
the screencast (103%)", and a clean run read 117%.

The criterion now counts the compositor's frames from the on-screen frame,
so both sides cover the same frames: the skipped cast fails like the full
one, and a clean run reads at most 100%. A busy.test.ts case pins all three.

* Merge #8505 (02ff48fafe: a watch stamps after the newest entry, and an exit removed at its end is still reported) into the frame evidence

* Merge #8045 (e05cdd163a, on #8505 02ff48fafe) into the recorder stand-down

# Conflicts:
#	internal/interaction-harness/src/curves.ts

* fix(interaction): waitForAnimation and a watch's started() stand down while a trace runs

Polled inside a traced body, each look added a Layout of an anchored element in every frame (24 in the anchored fixture's steady middle). Four reads did it, each alone: Playwright's locator.evaluate (each call makes a main-thread frame), the recorder's document-wide read, an unwatched wait's element.getAnimations(), and effect.getComputedTiming(), which updates a running animation on demand and restyles its target. While a trace runs, record() from outside the recorder and arm()'s read stand down; the wait takes the element's animations from the recorder's log, judges a started one by its own times (playState only before it has a start time), reads timing through getTiming(), and reaches the element through a handle to what the locator matches at each look. The same window now lays out 0 times.

* Merge main (#8505 landed) into the storyboards

# Conflicts:
#	internal/interaction-harness/README.md
#	internal/interaction-harness/src/curves.ts
#	internal/interaction-harness/src/harness.interaction.browser.spec.ts

* Merge #8348 (main with #8505 landed) into the frame evidence

# Conflicts:
#	internal/interaction-harness/src/curves.ts
#	internal/interaction-harness/src/harness.interaction.browser.spec.ts

* Merge #8045 (main with #8505 landed) into the recorder stand-down

# Conflicts:
#	internal/interaction-harness/src/curves.ts

* fix(interaction): the WebKit container runs as the invoking user under docker

Docker ran the Playwright image as root over the bind-mounted checkout, so
the test-results/ it wrote were root's and the next host run failed with
EACCES clearing them. Under docker the container now runs with the host uid
and gid (HOME=/tmp); rootless podman already maps the user. The README says
so.

* docs(interaction): a traced waitForAnimation on a CSS @keyframes animation still flushes its style

The traceRendering JSDoc and the README said waitForAnimation and a watch's
started() stand down inside a trace. That holds for script animations and
CSS transitions; on a CSS @keyframes animation (and, unwatched, a cancelled
CSS transition) they read the animation's timing, which flushes its style.
Both now say so and point to waitForRest or a wait after the trace.

* fix(interaction): rootless docker runs the WebKit container without --user

Rootless Docker maps the container's root to the invoking user, and any
other container uid to a subordinate host uid that cannot write the mounted
checkout, so --user made the WebKit run fail with EACCES there. The script
passes --user only to a rootful daemon (docker info reports no
name=rootless); the README and header say so.

* docs(interaction): waitForAnimation's JSDoc names the CSS @keyframes gap, as the README does

A traced waitForAnimation on a CSS @keyframes animation (and, unwatched, next to a cancelled CSS transition) reads the animation's timing, which flushes its style. The README's No Layout / Paint paragraph and traceRendering's JSDoc say so; waitForAnimation's own JSDoc still said it adds no frame.

* Merge #8348 (38ea7d9c: rootless docker, WebKit container user) into the frame evidence

# Conflicts:
#	internal/interaction-harness/scripts/webkit-container.mjs

* chore(interaction): move four instruments out of the frame evidence

Scope: castStoryboard, sampleRects, openStory's Storybook highlight default
and the WebKit container's shader cache move to a follow-up pull request,
unchanged, with their self-tests and README text. The frame evidence keeps
the screencast, marker tracking, frame stats and the busy-main-thread proof.

* chore(interaction): move rendering traces and the long-task log out of the frame evidence

Scope: traceRendering, countBetween and the long-task log (with the
harness-read marks the trace uses, their self-tests and README text) move to
a follow-up pull request, unchanged. trace.ts keeps startCompositorTrace,
which the busy-main-thread proof reads.

* chore(interaction): move the compositor presentation record out of the frame evidence

Scope: startCompositorTrace (trace.ts), its Chromium self-test and the
recorded skip fixture with its unit test move to the follow-up pull request
with the rendering traces, unchanged. busyWindowProof keeps its presented
option and its synthetic compositor-record tests; the README and JSDoc say
where a record comes from without naming the moved helper.

* feat(interaction): Chromium rendering traces, the composito…

This branch was successfully deployed

1 active deployment
Preview — 37050d36 Deployed Oct 25, 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