Skip to content

test(core): the touch presentation's browser proofs run on the interaction harness - #8376

Draft
vjeux wants to merge 40 commits into
meta:mainfrom
vjeux:uikit/f2-touch-presentation-proofs
Draft

vjeux wants to merge 40 commits into
meta:mainfrom
vjeux:uikit/f2-touch-presentation-proofs

Conversation

@vjeux

@vjeux vjeux commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Review only this pull request's three test files: vjeux/astryx@39ed04d...f9638b4

Why

Nothing in CI checks what a real browser engine answers for Astryx's touch presentation: whether a phone reads as touch and compact, whether the iOS-only styles stay off elsewhere, and where an adaptive menu switches to a bottom sheet. A regression there would show a desktop menu on phones, or a sheet on a laptop, and only a person holding a device would notice.

What led to it

This is part of the UIKit-parity work that makes Astryx's touch components behave as they do on iOS. #8062 added the touch presentation with real-engine proofs, but those ran under their own Playwright config, which no CI job runs, so #8062 dropped them. The interaction harness (#8045) has since landed, and CI runs its phone projects.

Options and tradeoffs

  • Keep the separate config and add a CI job for it: one more job to maintain, for three tests.
  • Rewrite the proofs as jsdom unit tests: cheap, but jsdom evaluates no media queries, so they would check nothing real.
  • Move them onto the interaction harness, which already runs Chromium and WebKit phone profiles in CI.

Why this choice

The harness is where real-engine coverage lives (spec:AST-030 FR11: new coverage joins its existing owner), so the proofs run in CI with no new job and no product change.

How to check it

  1. Run pnpm exec playwright test -c playwright.interaction.config.ts --project chromium packages/core/src/utils/__tests__/browser/. Expected: 3 passed.
  2. Run the same three in WebKit, in mcr.microsoft.com/playwright:v1.61.1-noble. Expected: 3 passed.
  3. What they check: under a finger the presentation reads touch and compact; a style under the touch query applies and one under the iOS style gate does not; an adaptive menu becomes a bottom sheet at 767 px wide or 499 px tall under a finger, never at 768 or 500, and never with a mouse (spec:AST-012 FR9).

Evidence

  • Only test files change: the two *.interaction.browser.spec.ts files and their fixture. The edges are checked in four iframes on one page, because a frame's media queries read its own size.
  • Mobile Safari results for the same edges (iOS 26.2 simulator, iPhone 17 Pro and iPad) are in feat(core): one touch presentation resolver #8062's description.
  • CI on this head: the visual regression runs were cancelled in a queue-wide cancel and have not rerun; they are not failures.

vjeux and others added 30 commits October 23, 2026 20:53
Touch behaviours key on a coarse primary pointer, layout behaviours on a
compact viewport, and presentations that need both (a sheet in place of a
popover) ask for both. utils/touchPresentation.ts holds the three queries,
SSR-safe hooks (useTouchPresentation; useMotionPresentation for components
with no interaction to infer from: UIKit motion under a finger, theme
tokens under a mouse) and a one-moment read. Styles write the same queries
as StyleX media keys, and a test keeps core's compact-touch keys spelled as
the shared query.

A compact viewport is narrower than 744 px or shorter than 500 px, the
closest a size gets to UIKit's compact size class: an iPad mini in portrait
keeps the anchored popover and a phone in landscape gets the sheet; a
Max-size iPhone in landscape is the known miss. The adaptive popover-or-
sheet presentation reads the shared query; its docs, stories and tests
follow.
A touch presentation is now also an idiom: ios for WebKit on iOS or iPadOS
under a coarse pointer (CSS.supports('-webkit-touch-callout: none'), which
no other engine implements), default everywhere else, Android included,
which keeps today's behaviour until a Material variant exists.
useTouchPresentation and readTouchPresentation report it, useTouchIdiom
reads it alone, and useMotionPresentation now chooses UIKit motion only in
the iOS idiom. Styles gate the same way with two nested StyleX keys, the
media query outside and the supports query inside (IOS_TOUCH_STYLE_KEYS),
as core's 16px input floor already does; a test keeps every such key in
core spelled and nested that way. A real-engine spec reads Chromium as an
Android phone and WebKit as default.
…documented behavior change

- The resolver answers the same on every engine: the iOS idiom
  (useTouchIdiom, useMotionPresentation, the idiom field) is dropped, so
  nothing chooses a platform; touch motion reads the theme's motion roles.
  The iOS style-gate constants stay, internal, for a style iOS WebKit itself
  requires (core's 16px input floor), and their spelling test stays.
- The changeset names the old and new default (sub-menu drill-in included)
  and the presentation prop that pins a component's presentation.
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.
Brings in the theme compact definition (spec:AST-012 FR9) this branch's
resolver reads.
…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.
COMPACT_QUERY and COMPACT_TOUCH_QUERY are the theme's compact definition
(spec:AST-012 FR9: width below the md point, 768 by default, or height
below 500, the same on every platform), so useAdaptivePresentation and the
sub-menu drill-in keep no size numbers of their own. Tests, docs, stories
and the changeset follow: a coarse-pointer screen exactly 768 wide keeps the
popover, and a phone in landscape gets the sheet.
…t width

The previous commit's formatter pass rewrapped the whole file; this keeps
the file as it was and changes only 744px to 768px in both languages.
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 AST-094 copy

- useTouchPresentation, useAdaptivePresentation and the sub-menu drill-in
  read the active theme's compact definition (useCompactDefinition, from
  its effective width points), so a theme that moves md moves compact with
  it (spec:AST-012 FR9). The exported constants stay the default-point
  values a StyleX media key spells. A test covers a theme with md 1000.
- The changeset names the edge moving with md and the sub-menu pin
  (presentation="flyout" on DropdownMenuSubMenu).
- The stacked copy of docs/specs/AST-094-touch-presentations/spec.md is
  removed: the size rule here rests on current spec:AST-012 FR9 and the
  Selector record, and AST-094 itself is proposed separately.
The size rule here rests on current spec:AST-012 FR9 and the Selector
record; AST-094 itself is proposed separately, so this branch no longer
carries an older revision of it.
The adaptive policy's width edge follows the theme's md breakpoint (768px by
default, spec:AST-012 FR9), so the docs (en and zh) and the two story
descriptions say that instead of a fixed 768px.
…ation

Nothing reads it: an adaptive overlay decides through
useAdaptivePresentation and the compact definition's coarse-pointer query.
useTouchPresentation and readTouchPresentation answer isTouch and
isCompact.
Four frames sized at the compact edges (767 and 768 px wide, 499 and 500 px
tall) report what useAdaptivePresentation('adaptive') picks: the bottom
sheet at 767 and 499 under a finger, the popover at 768 and 500 and
everywhere under a mouse, in Chromium and WebKit.
…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.
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.
`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.
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.
…ed 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.
…ading 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.
…ebKit 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.
vjeux added 8 commits October 24, 2026 22:10
…cription

They ran under their own Playwright config, which no CI job picks up;
spec:AST-030 FR11 asks new coverage to join its existing owner. Their
results (Chromium and WebKit, and Mobile Safari on the iOS 26.2 simulator)
stay in the pull request description, and the unit suites keep the
resolver's answers.
Nothing pinned useMenuDrillIn's theme read: a fixed default query left the
suites green. A 900 px coarse screen now flies the sub-menu out under the
default md (768) and drills in under a theme whose md is 1000, as the root
sheet does (spec:AST-012 FR9).
… reads

Its default ignored a theme that moves md; nothing calls it yet, so the
compact definition is now required and its first caller passes the
theme's (useCompactDefinition()). The changeset names ContextMenu's
sub-menus, whose drill-in changes too.
…ction harness

They ran under their own Playwright config, which no CI job picked up
(spec:AST-030 FR11). As *.interaction.browser.spec.ts they join the
interaction harness's phone projects, which CI runs: the resolver's answers
and the iOS style gate under a finger, and an adaptive menu taking the
sheet only below the compact edges (767 px wide, 499 px tall), under a
finger and never with a mouse. The one-moment read passes its compact
definition.
@vercel

vercel Bot commented Oct 25, 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 27, 2026 11:50am UTC

Request Review

@vjeux vjeux mentioned this pull request Oct 25, 2026
3 tasks done
@astracat-bot

astracat-bot Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Oct 27, 2026, 12:26 PM UTC

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 25, 2026

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Changes requested, for scope rather than for the proofs themselves. Because this branch merges #8045 and #8062, its diff is 7,105 authored lines across 49 files, including the whole interaction harness and #8062's presentation="adaptive" behavior change, not only the three proof files the description covers. Please land #8045 and #8062 first (or retarget onto them) and rebase, so this pull request carries only adaptiveEdges.interaction.browser.spec.ts, touchPresentation.interaction.browser.spec.ts and fixture.ts; until then, the description's "no product change" and "only test files" statements do not match the diff. The proofs look right: they pass in Chromium and fail at the 768 px edge when the adaptive hook gets the old (max-width: 768px) query.

[Automated review]

@vjeux

vjeux commented Oct 25, 2026

Copy link
Copy Markdown
Contributor Author

Thanks. The proofs stay as they are.

@github-actions

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR

🧪 Sandbox Preview

View Sandbox for this PR

No new or modified components detected.

Bundle Size Summary

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

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

Accessibility Audit

Status: No accessibility violations detected.


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

vjeux added 2 commits October 27, 2026 04:26
…n resolver as they landed) into the touch presentation proofs

# Conflicts:
#	.github/scripts/ci-interaction.test.mjs
#	.github/scripts/ci-test-routing.test.mjs
#	.github/workflows/ci.yml
#	internal/interaction-harness/README.md
#	internal/interaction-harness/package.json
#	internal/interaction-harness/scripts/webkit-container.mjs
#	internal/interaction-harness/src/curves.ts
#	internal/interaction-harness/src/harness.interaction.browser.spec.ts
#	internal/interaction-harness/src/index.ts
#	internal/interaction-harness/src/math.test.ts
#	internal/interaction-harness/src/math.ts
#	internal/interaction-harness/src/scripts.ts
#	internal/interaction-harness/src/storyboard.ts
#	package.json
#	playwright.interaction.config.ts
… on main's harness

Main's interaction harness landed without the busy-thread, frame and trace
modules and the CI wiring this branch carried from the harness's own pull
request, and the BottomSheet detent proof belongs to another branch. This
pull request now changes only its three files: the adaptive-edges and
touch-presentation proofs and their fixture.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Recommended for approval: the branch now carries only the three proof files on main's interaction harness, and they match spec:AST-012 FR9, spec:AST-094 FR13 and spec:AST-030 FR11. The proofs pass in Chromium and WebKit, and they catch an inclusive FR9 edge, an adaptive hook with its own size numbers or without its pointer check, and an iOS gate that matches outside iOS. Optionally, refresh the description: it still lists #8045 as a dependency and asks for a review of one earlier commit, and its testing command needs --project=chromium, because with a space Playwright reads the path as a second project name.

[Automated review]

@cixzhang cixzhang closed this Oct 28, 2026
@cixzhang cixzhang reopened this Oct 28, 2026
@cixzhang

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf: closed and reopened only to restart this PR's CI on the current workflow after tonight's queue-wide cancel; nothing in the PR changed.

@vjeux
vjeux marked this pull request as draft October 29, 2026 22:36

This branch was successfully deployed

1 active deployment
Preview — f9638b47 Deployed Oct 27, 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