Uh oh!
There was an error while loading. Please reload this page.
motion-waiter: wait for a sliding element to settle before clicking it - #42
motion-waiter: wait for a sliding element to settle before clicking it#42mmkal wants to merge 8 commits into
Conversation
Fleshed-out spec for teaching middlewright about motion: Playwright's 2-frame stability check misses timer-driven JS animation, so clicks land mid-slide (seen with a RN Animated drawer in iterate). Plan: a bounding- box-sampling motionWaiter plugin with a 1s settle budget, plus a slow- drawer demo spec producing before/after videos. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
commit: |
Playwright's stability check compares only two consecutive frames, so
timer-driven JS animation (RN-web Animated, setInterval steppers) that
steps coarser than the display refresh gets clicked mid-slide. The new
plugin samples the target's box before pointer actions: a static element
passes after one confirming sample (~60ms), observed motion demands a
150ms quiet window, and everything is capped by a 1.5s settle budget so
perpetual motion proceeds with a log line instead of blocking.
The drawer demo spec is the proof: the control click lands at
translateX(-240.8px) on a 280px drawer (~14% open); with motionWaiter
the same click lands at 0. Explicit { timeout } passes through, which
also composes with spinner-waiter's fast-fail (its injected 1ms timeout
skips the motion wait).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>The zero-quiet fast path would sail through an element parked a frame or two before its animation starts (RN's open → requestAnimationFrame → animate shape — the motivating bug), so the action now proceeds only after settledFor (150ms) of observed stillness — ~150-200ms per pointer action on static elements, the price of catching pause-then-slide. Demo: the drawer app freezes its slide with a pressed-item flash when a click lands, so the before video visibly strands the drawer part way out; both tests caption their videos via page.videoMode.caption. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mmkal
commented
Aug 29, 2026
Maybe let's have a settings system so this can be off by default but opt in in an async local storage callback just for specific parts that we know have problematic animations. Similar to the await page.spinnerWaiter.settings.run({ spinnerTimeout: 12345 }, async () => ...) |
Every guarded action pays the stillness window, so motion checking now
defaults to enabled: false. Opt in around known-problematic animations
(motionWaiter.settings.run({ enabled: true }, () => item.click())), or
pass enabled: true at registration to guard a whole suite. The demo's
after-test showcases the per-block opt-in.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>mmkal
commented
Aug 29, 2026
🤖 Done in c114d58 — |
mmkal
commented
Aug 29, 2026
There's a flash before and after the the animation |
The flash at the start and end of the demo videos had two causes: 1. The motion-settle hold runs before video-mode's middleware, so video-mode recorded it as pre-action dead air and compression fast-forwarded the drawer's slide — frames of the dimmed mid-slide state leaked into the rendered intro as a flash. Fix via neutral middleware context (AGENTS.md plugin boundaries): ActionTiming gains watchableSpans, motion-waiter flags its hold there when it actually saw motion, and video-mode carves those spans out of dead air the same way popup enter/exit animations are protected. The slide now renders at full speed. 2. The demo app popped its backdrop in and out instantly (one-frame dim/undim), and navigated while the overlay was mid-fade, so the final freeze frame ghosted half-faded menu items. The overlay now fades in/out and navigation happens only after the menu is fully gone. New spec proves the carve-out: a slide-settling click must not leave a dead-air span covering the hold (fails without the video-mode change). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mmkal
commented
Aug 29, 2026
🤖 Fixed in d1d1ce5, and it turned out to be two things. The start flash was a real video-mode gap: the motion-settle hold runs before video-mode's middleware, so it got recorded as pre-action dead air and the slide was fast-forwarded — a dimmed mid-slide frame leaked into the rendered intro. Fixed via neutral middleware context per AGENTS.md: ActionTiming.watchableSpans, which motion-waiter fills when it saw motion and video-mode carves out of dead-air compression (same protection popup enter/exit animations get), with a spec that fails without the carve-out. The end flash was the demo app's fault — it popped the backdrop instantly and navigated mid-fade, ghosting half-faded menu items into the final freeze; it now fades the overlay and navigates only once the menu is gone. Fresh videos in the PR body — the slide now plays through at full speed. |
The slide vanished from the rendered demo entirely: two pointer actions within one highlight-hold of each other trigger the overlap-skip, which jumped straight across the drawer's travel. Watchable spans now feed renderKeepSpans — the same guard that stops skips leaping over popup enter/exit animations — so flagged footage survives both dead-air compression and hold skips. The remaining end-seam artifacts (a pre-navigate Home frame and a black frameless slice leaking around the heading waitFor's highlight) go away by not highlighting the waitFor at all (skipMethods: ["waitFor"]) — footage runs continuously from the click through the pressed flash and fade. The demo app now navigates as the fade starts (the new screen is what the fade reveals), and the tests outlast the fade so the final hold freezes a settled frame. Verified frame-by-frame at 25fps: both videos play travel → slide → pressed flash → fade → settled screen with no flashes, ghosts, or black frames. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Agents reaching for a sleep or a timeout to let a drawer finish sliding now get told about the tool built for it, in both require-timeout-comment messages. The lint-plugin spec pins the hint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Teaches middlewright about motion: a new
motionWaiterplugin that holds pointer actions (click/dblclick/hover) until the target stops moving.Playwright's own stability check only requires the bounding box to match across TWO consecutive frames, so timer-driven JS animation (RN-web
Animated's JS driver,setIntervalsteppers) that steps coarser than the display refresh gets clicked mid-slide. motionWaiter samples the target's bounding box over a longer window and proceeds only after 150ms of observed stillness — technique-agnostic (CSS, WAAPI, rAF and timer steppers all move the box), and deliberately a real window rather than a single confirming sample, because an element often sits parked a frame or two before its animation starts (RN's open → requestAnimationFrame → animate shape, the bug that motivated this in iterate/iterate#2547).It is off by default — register it, then opt in around the specific interactions whose animations are known to be problematic:
Before
Vanilla Playwright clicks the menu item when the drawer is ~20% out (
translateX(-225px)of 280px, recorded by the demo app at click time) — the drawer "only slides part way" because the click ended the flow there:drawer-before.mp4
After
Same app, same clicks — the click waits for the slide to settle and lands at
translateX(0):drawer-after.mp4
Cost
Guarded actions deliberately slow down: a static element pays ~150-200ms (the stillness window), and actual motion is waited out up to a 1.5s
settleTimeout— perpetual motion (marquees) proceeds at the deadline with a log line, never blocks. Because of that cost the plugin is opt-in (enabled: falseby default): enable per block viasettings.run, per test viasettings.enterWith, or suite-wide withmotionWaiter({ enabled: true }). An explicit{ timeout }passes straight through — which is also what makes[spinnerWaiter(), motionWaiter()]compose (spinner-waiter's fast-fail injects a 1ms timeout that skips the motion wait).Risk map
src/plugins/motion-waiter.ts): step cadences slower thansampleInterval(60ms) can pass the stillness window mid-hold — documented boundary, same blind spot as vanilla Playwright, defeated in practice by the 150mssettledForquiet requirement.src/plugins/motion-waiter.ts,spec/motion-waiter.spec.ts,spec/motion-drawer-demo.spec.ts; README/exports are mechanical.video-mode: watchable spans
The demo exposed a video-mode gap: footage during a motion-settle hold got dropped twice over — recorded as pre-action dead air (the hold runs before video-mode's middleware) and skipped entirely when two pointer actions land within one highlight-hold of each other (the overlap-skip). Per the plugin-boundary rule, the fix is neutral middleware context:
ActionTiming.watchableSpans, which motion-waiter fills when it actually observed motion, and video-mode both carves out of dead-air compression and feeds into the overlap-skip guard — the same two protections popup enter/exit animations get. A spec asserts the dead-air carve-out; the demo videos above are the skip-guard's proof (the slide plays through).Shared visual baseline
spec/todo-app.spec.tsrendered on this branch (per AGENTS.md):todo-app-baseline.mp4
🤖 Generated with Claude Code
Session: 14cf93bf-a678-421e-a4b5-00f4228ba4cc — "PR 2547: un-suppress waitForTimeout sleeps"
Note
Medium Risk
Opt-in pointer actions add ~150ms+ latency and bounded settle loops when enabled; video-mode dead-air/render logic changes affect all recordings using middleware-flagged watchable spans.
Overview
Adds
motionWaiter, a new middleware plugin that delaysclick,dblclick, andhoveruntil the target’s bounding box stays still for ~150ms—addressing mid-slide clicks when timer-stepped JS animation fools Playwright’s two-frame stability check. It is off by default; tests opt in withmotionWaiter.settings.run({ enabled: true }, …)or suite-wideenabled: true, and explicit{ timeout }bypasses the wait (so it composes withspinnerWaiter).video-mode gains
ActionTiming.watchableSpans: motion-waiter records settle waits as watchable footage, which is carved out of dead-air compression and protected during render overlap-skips (same idea as popup animations). The oxlintrequire-timeout-commentguidance now points animation waits at motionWaiter; README and demo/unit specs document the drawer before/after behavior.Reviewed by Cursor Bugbot for commit 4f004fd. Bugbot is set up for automated code reviews on this repo. Configure here.