Restore native scrolling and reliable recognition; add feel tests

The previous round regressed the demo against main: dropped frames while
a finger drags, and swipes ignored when they start imperfectly.

Frame drops: hosts used touch-action: manipulation plus a blocking
touchmove listener, so Chrome waited for JavaScript on every touch move
(~30% of frames over 20ms in a drag, p95 28ms). Hosts again use per-axis
touch-action like main (pan-y for horizontal gestures, and so on), and
attach no touch listener.

Flaky recognition: an irreversible decision after 6px dropped swipes
with a wobbly or backward start. Recognition again measures from the
pointer-down point until the pointer lifts, as on main.

Scrolling beside gestures, natively:
- .from.scrollEdge() uses direction-specific touch-action while its
  scroller is at the edge (pan-x pan-down for drag-down at the top), the
  pull-to-refresh pattern from the Pointer Events spec. A passive scroll
  listener keeps it current; no touch handling blocks the browser.
- Where directional values are unsupported (Safari), scroll-edge
  gestures alone fall back to claiming touches at the browser's first
  cancelable touchmove.
- Hosts publish touch-action as --nvo-touch-action and the stylesheet
  applies it to descendants, so nested scroll containers keep it and
  horizontal swipes over lists are not cancelled. Ignored subtrees get
  touch-action: auto back.
- Bindings gain listeners and ref (spread on the host); the components
  use them.

Feel tests (npm run test:feel): Chromium with emulated touch checks
recognition on ten human-like traces, frame pacing budgets while
dragging (p95 <= 20ms, <= 5% over 20ms) for a swipe and a scroll-edge
dismiss, and the behaviour checks. Against the previous code they fail
on exactly the reported regressions; now 23/23 pass. On the demo, all
traces match main and drag frame pacing matches or beats it.

Commit thresholds are unchanged (0.25 distance, 0.2 flick).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
This commit is contained in:
Claude
2026-11-03 05:56:26 +00:00
parent 6889fc0112
commit caf2ad692a
13 changed files with 558 additions and 226 deletions
+2 -1
View File
@@ -49,7 +49,8 @@
"electron": "npm run build && electron apps/electron/main.mjs", "electron": "npm run build && electron apps/electron/main.mjs",
"cap:sync": "npm run build && npm --prefix apps/capacitor exec cap sync", "cap:sync": "npm run build && npm --prefix apps/capacitor exec cap sync",
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,css,md}\"", "format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,css,md}\"",
"prepare": "npm run build --workspace packages/core-v2" "prepare": "npm run build --workspace packages/core-v2",
"test:feel": "NODE_ENV=development vite build --mode development --config packages/core-v2/verify/browser/app/vite.config.mjs && node packages/core-v2/verify/browser/run.mjs"
}, },
"devDependencies": { "devDependencies": {
"@capacitor/app": "^8.0.0", "@capacitor/app": "^8.0.0",
+70 -32
View File
@@ -220,11 +220,10 @@ const gestures = [forwardGesture, backGesture] as const;
| `gestures` | `readonly OriginGestureDefinition[]` | required | Fully defined page-owned rules | | `gestures` | `readonly OriginGestureDefinition[]` | required | Fully defined page-owned rules |
The component adds no recognition or navigation policy. It installs each The component adds no recognition or navigation policy. It installs each
definition, forwards every pointer and touch event to every recognizer, and definition, forwards every pointer event to every recognizer, and derives one
lets the recognizers share the touch: each locks onto a touch moving along its `touch-action` that leaves native scrolling on every pan no gesture needs. A
own axis and direction and lets go of any other, so a horizontal drawer, a horizontal drawer and a scroll-edge pull-down can share a host with vertical
pull-up sheet, and vertical scrolling can live on one host. See scrolling. See [Scrolling](#scrolling).
[Scrolling](#scrolling).
The `gestures` list is reactive, including its length. Recognizers are kept per list position, so a re-render with new The `gestures` list is reactive, including its length. Recognizers are kept per list position, so a re-render with new
definition objects never interrupts a gesture in progress; each change applies definition objects never interrupts a gesture in progress; each change applies
@@ -546,25 +545,43 @@ interface OriginGestureBinding {
readonly onPointerup: (event: PointerEvent) => void; readonly onPointerup: (event: PointerEvent) => void;
readonly onPointercancel: () => void; readonly onPointercancel: () => void;
readonly onLostpointercapture: (event: PointerEvent) => void; readonly onLostpointercapture: (event: PointerEvent) => void;
readonly onTouchmove: (event: TouchEvent) => void; readonly listeners: Readonly<Record<string, (event: Event) => void>>;
readonly ref: (element: unknown) => void;
} }
``` ```
Apply all handlers to the same element, as non-passive listeners (Vue's Apply the pointer handlers, the returned style, and `listeners` to the same
`@touchmove` is), and apply the returned style. The style sets dimensions and element, and pass `ref` as its function ref:
`touch-action: manipulation`; `onTouchmove` is what keeps native scrolling out
of a gesture the recognizer has claimed. Without it, the browser takes over ```vue
vertical and horizontal drags with `pointercancel`. <div
:ref="binding.ref"
:style="binding.style"
v-bind="binding.listeners"
@pointerdown="binding.onPointerdown"
@pointermove="binding.onPointermove"
@pointerup="binding.onPointerup"
@pointercancel="binding.onPointercancel"
@lostpointercapture="binding.onLostpointercapture"
/>
```
The style sets dimensions and the host's `touch-action` (see
[Scrolling](#scrolling)). `listeners` is empty for ordinary gestures; for
scroll-edge gestures it tracks the scroller, and where the browser lacks
directional `touch-action` it adds a `touchmove` handler.
Recognition requires: Recognition requires:
1. A primary, left-button pointer satisfies the optional start policy. 1. A primary, left-button pointer satisfies the optional start policy.
2. The target is not an ignored interactive element. 2. The target is not an ignored interactive element.
3. The first 6px of movement are along the gesture's axis and direction (see 3. Directed movement from the pointer-down point reaches `threshold`.
[Scrolling](#scrolling)). 4. Directed movement exceeds cross-axis movement by `axisDominance`.
4. Directed movement reaches `threshold`. 5. The navigation factory returns an intent.
5. Directed movement exceeds cross-axis movement by `axisDominance`.
6. The navigation factory returns an intent. Recognition is measured from where the pointer went down and keeps watching
until the pointer lifts, so a slightly wobbly or backward start only delays
it.
Progress is directed distance divided by the gesture's `distance` (by default Progress is directed distance divided by the gesture's `distance` (by default
the host's width or height). Past either end, the excess reaches the host's width or height). Past either end, the excess reaches
@@ -927,24 +944,43 @@ cause:
### Scrolling ### Scrolling
Gestures and native scrolling share the same touches. Each recognizer decides Native scrolling stays in the browser's hands: gesture hosts never make it
within the first 6px of movement whether a touch is its own: wait for JavaScript. Each host gets a `touch-action` that keeps every pan no
gesture needs:
- **along its axis and direction**: it holds the touch, and the binding's | Gestures on the host | `touch-action` |
`onTouchmove` cancels native scrolling while it does; | --------------------------------------------------------------- | ---------------- |
- **along the other axis, or the opposite way**: it lets go, and the browser | Horizontal only | `pan-y` |
scrolls (or another gesture on the host takes it); | Vertical only | `pan-x` |
- **diagonal**: it keeps watching until the movement is clear. | Both axes | `none` |
| `.from.scrollEdge(list, "top")` + `.to.down()`, list at the top | `pan-x pan-down` |
| The same, list scrolled | `manipulation` |
Browsers hold back the first cancelable `touchmove` for roughly 15–20px, so the The browser therefore scrolls the free axis natively from the first frame,
decision is made before native scrolling can start. This is why gesture hosts and touches on a gesture's axis reach the recognizer untouched.
use `touch-action: manipulation`: the browser may pan either axis, and the
recognizers keep it from doing so only for touches they claim. A vertical
dismiss gesture therefore no longer disables vertical scrolling; combine it
with `.from.scrollEdge()` so it only claims a downward drag at the top.
Nested scrollers need nothing special. Their `touchmove` events bubble to the A gesture that shares its axis with a scroller, such as drag-down-to-dismiss
gesture host, which cancels them only while it holds the touch. above a list, uses `.from.scrollEdge()`. While the list is at that edge, the
host reserves only the one pan the gesture needs: at the top, dragging down is
the gesture's and dragging up still scrolls the list natively. This is the
pull-to-refresh pattern from the Pointer Events specification, and it relies
on direction-specific `touch-action` values (`pan-down` and its siblings). The
host follows the list's scroll position through a passive scroll listener, so
the right value is in place before each touch starts.
Browsers without direction-specific values (Safari) fall back to deciding in
JavaScript: the host keeps the axis pannable and cancels native scrolling
from a `touchmove` handler for touches the gesture claims, at the browser's
first cancelable `touchmove` (after roughly 15–20px of movement). Blocking
touch handlers make the browser wait for JavaScript on every move, so this
fallback is used only for scroll-edge gestures and only where it is needed.
Nested scroll containers: browsers apply `touch-action` only up to the nearest
scroll container, so a list scrolling inside a horizontal gesture host would
otherwise let the browser take horizontal drags and cancel the gesture. Each
host publishes its value as the inherited `--nvo-touch-action` custom property,
and the package stylesheet applies it to descendants, so nested lists keep
`pan-y` too. This requires `@native-vue-router/core-v2/style.css`.
### Ignored elements ### Ignored elements
@@ -952,7 +988,9 @@ Pointer-down on `input`, `textarea`, `select`, `option`, or
`[contenteditable="true"]` never starts a gesture, so text selection and `[contenteditable="true"]` never starts a gesture, so text selection and
form controls keep working. Mark anything else that must keep its own drags form controls keep working. Mark anything else that must keep its own drags
with `data-origin-gesture="ignore"`, for example a horizontally scrolling chip with `data-origin-gesture="ignore"`, for example a horizontally scrolling chip
row or a slider inside a host that has a horizontal gesture. row or a slider inside a host that has a horizontal gesture. Ignored subtrees
also get `touch-action: auto` back from the package stylesheet, so a chip row
scrolls horizontally inside a horizontal gesture host.
### Animation performance ### Animation performance
@@ -20,7 +20,8 @@ const gesture = useOriginGesture(() => props.gesture);
<template> <template>
<component <component
:is="as" :is="as"
v-bind="$attrs" :ref="gesture.ref"
v-bind="{ ...$attrs, ...gesture.listeners }"
class="nvo-gesture" class="nvo-gesture"
:style="gesture.style" :style="gesture.style"
@pointerdown="gesture.onPointerdown" @pointerdown="gesture.onPointerdown"
@@ -28,7 +29,6 @@ const gesture = useOriginGesture(() => props.gesture);
@pointerup="gesture.onPointerup" @pointerup="gesture.onPointerup"
@pointercancel="gesture.onPointercancel" @pointercancel="gesture.onPointercancel"
@lostpointercapture="gesture.onLostpointercapture" @lostpointercapture="gesture.onLostpointercapture"
@touchmove="gesture.onTouchmove"
> >
<slot /> <slot />
</component> </component>
@@ -19,7 +19,8 @@ const surface = useOriginGestures(() => props.gestures);
<template> <template>
<component <component
:is="as" :is="as"
v-bind="$attrs" :ref="surface.ref"
v-bind="{ ...$attrs, ...surface.listeners }"
class="nvo-gesture" class="nvo-gesture"
:style="[$attrs.style, surface.style]" :style="[$attrs.style, surface.style]"
@pointerdown="surface.onPointerdown" @pointerdown="surface.onPointerdown"
@@ -27,7 +28,6 @@ const surface = useOriginGestures(() => props.gestures);
@pointerup="surface.onPointerup" @pointerup="surface.onPointerup"
@pointercancel="surface.onPointercancel" @pointercancel="surface.onPointercancel"
@lostpointercapture="surface.onLostpointercapture" @lostpointercapture="surface.onLostpointercapture"
@touchmove="surface.onTouchmove"
> >
<slot /> <slot />
</component> </component>
+38 -27
View File
@@ -1,9 +1,9 @@
import { toValue, type CSSProperties, type MaybeRefOrGetter } from "vue"; import type { CSSProperties, MaybeRefOrGetter } from "vue";
import type { OriginChoreography } from "../choreography"; import type { OriginChoreography } from "../choreography";
import type { OriginNavigationIntent } from "../navigation"; import type { OriginNavigationIntent } from "../navigation";
import type { OriginContext } from "../scene/types"; import type { OriginContext } from "../scene/types";
import type { OriginRect } from "../shared"; import type { OriginRect } from "../shared";
import { withinEdge } from "./recognizer"; import { isAtScrollEdge, withinEdge } from "./recognizer";
/* /*
* What a gesture is: the public gesture types, component props, and the * What a gesture is: the public gesture types, component props, and the
@@ -78,6 +78,15 @@ export type OriginGestureStart =
/** Recognize pointer-down when application policy returns `true`. */ /** Recognize pointer-down when application policy returns `true`. */
readonly kind: "when"; readonly kind: "when";
readonly predicate: OriginGestureStartPredicate; readonly predicate: OriginGestureStartPredicate;
}
| {
/**
* Recognize pointer-down while a scroller is at one edge. The gesture
* shares its axis with that scroller's native scrolling.
*/
readonly kind: "scroll-edge";
readonly scroller: OriginGestureScroller;
readonly edge: OriginGestureEdge;
}; };
/** Direction-recognition tuning accepted by `.to.left()` and its siblings. */ /** Direction-recognition tuning accepted by `.to.left()` and its siblings. */
@@ -324,8 +333,10 @@ export interface OriginGestureBuilder {
/** /**
* DOM bindings returned by {@link useOriginGesture}. * DOM bindings returned by {@link useOriginGesture}.
* *
* Attach all six handlers to the same `HTMLElement`, and apply * Attach the pointer handlers to the same `HTMLElement` (and `onTouchmove`
* {@link style}. * when {@link needsTouchmove} is true), and apply
* {@link style}: its `touch-action` keeps native scrolling on every axis no
* gesture claims.
*/ */
export interface OriginGestureBinding { export interface OriginGestureBinding {
/** /**
@@ -347,11 +358,28 @@ export interface OriginGestureBinding {
*/ */
readonly onLostpointercapture: (event: PointerEvent) => void; readonly onLostpointercapture: (event: PointerEvent) => void;
/** /**
* Touch-move handler. Cancels native scrolling while a recognizer holds * Touch-move handler for gestures that share an axis with native scrolling
* the touch, and leaves it alone once every recognizer has let go. It must * (`.from.scrollEdge()`): it cancels scrolling for a touch such a gesture
* be attached as a non-passive listener (Vue's `@touchmove` is). * claims, and leaves every other touch to the browser. Attach it as a
* non-passive listener (Vue's `@touchmove` is), and only when
* {@link needsTouchmove} is true.
*/ */
readonly onTouchmove: (event: TouchEvent) => void; readonly onTouchmove: (event: TouchEvent) => void;
/**
* Whether any gesture shares an axis with scrolling, so `onTouchmove` must
* be attached. Leave it off otherwise: a non-passive touch listener makes
* the browser wait for JavaScript on every touch move, which costs frames.
*/
readonly needsTouchmove: boolean;
/**
* Extra listeners the host needs, as Vue props: `v-bind` them on the host.
* Scroll-edge gestures track their scroller here (a passive scroll
* listener), plus `onTouchmove` where the browser lacks directional
* `touch-action`. Empty for ordinary gestures.
*/
readonly listeners: Readonly<Record<string, (event: Event) => void>>;
/** Function ref for the host element: `:ref="binding.ref"`. */
readonly ref: (element: unknown) => void;
} }
// Component props ---------------------------------------------------------- // Component props ----------------------------------------------------------
@@ -517,7 +545,7 @@ const fromBuilder: OriginGestureFromBuilder = Object.freeze({
when: (predicate: OriginGestureStartPredicate) => when: (predicate: OriginGestureStartPredicate) =>
fromStage({ kind: "when", predicate }), fromStage({ kind: "when", predicate }),
scrollEdge: (scroller: OriginGestureScroller, edge: OriginGestureEdge) => scrollEdge: (scroller: OriginGestureScroller, edge: OriginGestureEdge) =>
fromStage({ kind: "when", predicate: atScrollEdge(scroller, edge) }), fromStage({ kind: "scroll-edge", scroller, edge }),
}); });
// Start rules --------------------------------------------------------------- // Start rules ---------------------------------------------------------------
@@ -555,25 +583,8 @@ export const atScrollEdge =
scroller: OriginGestureScroller, scroller: OriginGestureScroller,
edge: OriginGestureEdge, edge: OriginGestureEdge,
): OriginGestureStartPredicate => ): OriginGestureStartPredicate =>
({ host }) => { ({ host }) =>
const element = isAtScrollEdge(scroller, edge, host);
typeof scroller === "string"
? host.querySelector(scroller)
: toValue(scroller);
if (!element) return true;
const { scrollTop, scrollLeft, scrollHeight, scrollWidth } = element;
const { clientHeight, clientWidth } = element;
switch (edge) {
case "top":
return scrollTop <= 0;
case "bottom":
return scrollTop + clientHeight >= scrollHeight - 1;
case "left":
return scrollLeft <= 0;
case "right":
return scrollLeft + clientWidth >= scrollWidth - 1;
}
};
/** Admit pointer-down when any of `predicates` does. */ /** Admit pointer-down when any of `predicates` does. */
export const anyOf = export const anyOf =
+217 -69
View File
@@ -1,4 +1,10 @@
import { computed, onScopeDispose, toValue, type MaybeRefOrGetter } from "vue"; import {
computed,
onScopeDispose,
shallowRef,
toValue,
type MaybeRefOrGetter,
} from "vue";
import type { OriginAction } from "../navigation"; import type { OriginAction } from "../navigation";
import { DEFAULT_COMMIT_THRESHOLD, OriginSceneError } from "../scene/graph"; import { DEFAULT_COMMIT_THRESHOLD, OriginSceneError } from "../scene/graph";
import { useOrigin, type UseOrigin } from "../scene/scene"; import { useOrigin, type UseOrigin } from "../scene/scene";
@@ -13,6 +19,7 @@ import type {
OriginGestureEdge, OriginGestureEdge,
OriginGesturePoint, OriginGesturePoint,
OriginGestureProgressDistance, OriginGestureProgressDistance,
OriginGestureScroller,
OriginGestureStart, OriginGestureStart,
} from "./definition"; } from "./definition";
@@ -54,17 +61,84 @@ export const crossDistance = (
return isHorizontal(direction) ? Math.abs(dy) : Math.abs(dx); return isHorizontal(direction) ? Math.abs(dy) : Math.abs(dx);
}; };
/** Whether a gesture shares its axis with native scrolling. */
export const sharesScroll = (definition: OriginGestureDefinition): boolean =>
definition.start.kind === "scroll-edge";
/** /**
* CSS `touch-action` for a gesture host. Recognizers arbitrate with native * Whether the browser supports direction-specific `touch-action` values
* scrolling themselves (see `onTouchmove`), so the browser may pan either axis * (`pan-up`, `pan-down`, …). Chromium does; where it is missing (Safari),
* and is only kept from double-tap zooming; a host without gestures is left * scroll-edge gestures fall back to claiming touches from JavaScript.
* alone. */
export const supportsDirectionalPan = (): boolean =>
typeof CSS !== "undefined" &&
typeof CSS.supports === "function" &&
CSS.supports("touch-action", "pan-down");
type PanDirection = "left" | "right" | "up" | "down";
/**
* The pan a finger moving `direction` would start. A finger moving down pans
* the content up (toward its top), which is what pull-to-refresh reserves.
*/
const PAN_OF: Record<OriginGestureDirection, PanDirection> = {
down: "up",
up: "down",
left: "right",
right: "left",
};
/**
* CSS `touch-action` for a gesture host. The browser keeps every pan that no
* gesture needs, so native scrolling never waits for JavaScript:
*
* - a gesture that owns its axis removes that axis (`pan-y` remains for a
* horizontal gesture);
* - a gesture sharing its axis with a scroller (`.from.scrollEdge()`) removes
* only the one pan its finger direction would start, and only while the
* scroller is at its edge (`pan-x pan-down` for drag-down at the top), the
* pull-to-refresh pattern from the Pointer Events specification.
*
* @param atEdge - Whether a scroll-edge definition's scroller is at its edge.
* @param directional - Whether directional pan values are supported; without
* them, shared axes stay fully pannable and `onTouchmove` claims touches.
*/ */
export const touchActionFor = ( export const touchActionFor = (
directions: Iterable<OriginGestureDirection>, definitions: Iterable<Pick<OriginGestureDefinition, "direction" | "start">>,
): "manipulation" | "auto" => { atEdge: (
for (const _ of directions) return "manipulation"; definition: Pick<OriginGestureDefinition, "start">,
return "auto"; ) => boolean = () => false,
directional = supportsDirectionalPan(),
): string => {
const pans = new Set<PanDirection>(["left", "right", "up", "down"]);
let any = false;
for (const definition of definitions) {
any = true;
if (definition.start.kind === "scroll-edge") {
if (directional && atEdge(definition))
pans.delete(PAN_OF[definition.direction]);
continue;
}
const pair: PanDirection[] = isHorizontal(definition.direction)
? ["left", "right"]
: ["up", "down"];
for (const pan of pair) pans.delete(pan);
}
if (!any) return "auto";
if (pans.size === 4) return "manipulation";
if (pans.size === 0) return "none";
const axis = (a: PanDirection, b: PanDirection, both: string) =>
pans.has(a) && pans.has(b)
? [both]
: pans.has(a)
? [`pan-${a}`]
: pans.has(b)
? [`pan-${b}`]
: [];
return [
...axis("left", "right", "pan-x"),
...axis("up", "down", "pan-y"),
].join(" ");
}; };
// Pointer-down admission ---------------------------------------------------- // Pointer-down admission ----------------------------------------------------
@@ -163,10 +237,40 @@ export const matchesStart = (
const point = pointOf(event, bounds); const point = pointOf(event, bounds);
if (start.kind === "when") if (start.kind === "when")
return start.predicate({ event, origin, host, bounds, point }); return start.predicate({ event, origin, host, bounds, point });
if (start.kind === "scroll-edge")
return isAtScrollEdge(start.scroller, start.edge, host);
return withinEdge(start.edge, start.distance, host, bounds, point); return withinEdge(start.edge, start.distance, host, bounds, point);
}; };
/**
* Whether `scroller` is scrolled all the way to `edge`. A scroller that is
* missing (not yet mounted) counts as at the edge.
*/
export const isAtScrollEdge = (
scroller: OriginGestureScroller,
edge: OriginGestureEdge,
host: HTMLElement,
): boolean => {
const element =
typeof scroller === "string"
? host.querySelector(scroller)
: toValue(scroller);
if (!element) return true;
const { scrollTop, scrollLeft, scrollHeight, scrollWidth } = element;
const { clientHeight, clientWidth } = element;
switch (edge) {
case "top":
return scrollTop <= 0;
case "bottom":
return scrollTop + clientHeight >= scrollHeight - 1;
case "left":
return scrollLeft <= 0;
case "right":
return scrollLeft + clientWidth >= scrollWidth - 1;
}
};
/** Whether `point` lies within `distance` of one side of the host. */ /** Whether `point` lies within `distance` of one side of the host. */
export const withinEdge = ( export const withinEdge = (
edge: OriginGestureEdge, edge: OriginGestureEdge,
@@ -244,7 +348,7 @@ export class DirectionalTracker {
} }
/** Movement from the start without recording a sample. */ /** Movement from the start without recording a sample. */
displacement(event: PointerSample) { displacement(event: Pick<PointerSample, "clientX" | "clientY">) {
const dx = event.clientX - this.start.clientX; const dx = event.clientX - this.start.clientX;
const dy = event.clientY - this.start.clientY; const dy = event.clientY - this.start.clientY;
return { return {
@@ -295,12 +399,6 @@ export class DirectionalTracker {
const DEFAULT_THRESHOLD = 8; const DEFAULT_THRESHOLD = 8;
/** Ratio by which directed movement must exceed cross-axis movement. */ /** Ratio by which directed movement must exceed cross-axis movement. */
const DEFAULT_AXIS_DOMINANCE = 1.15; const DEFAULT_AXIS_DOMINANCE = 1.15;
/**
* Movement in CSS pixels after which a recognizer decides whether a touch is
* along its axis. Browsers hold back the first cancelable `touchmove` for
* roughly 15–20px, so the decision is made before native scrolling can start.
*/
const AXIS_LOCK_DISTANCE = 6;
/** Resolve a definition's progress distance at pointer-down, in pixels. */ /** Resolve a definition's progress distance at pointer-down, in pixels. */
export const resolveProgressDistance = ( export const resolveProgressDistance = (
@@ -340,11 +438,12 @@ interface PointerSession {
readonly startTime: number; readonly startTime: number;
readonly tracker: DirectionalTracker; readonly tracker: DirectionalTracker;
/** /**
* Whether the movement so far is along this gesture's axis and direction * For a gesture sharing its axis with scrolling: whether it has claimed
* (`ours`) or still too small or too diagonal to tell (`pending`). A touch * this touch from native scrolling (`ours`) or not yet decided.
* that turns out to be someone else's closes the session instead.
*/ */
axis: "pending" | "ours"; scroll: "pending" | "ours";
/** Latest pointer position, for the touch-move decision. */
last: { readonly clientX: number; readonly clientY: number };
/** Movement passed the recognition threshold and the pointer is captured. */ /** Movement passed the recognition threshold and the pointer is captured. */
captured: boolean; captured: boolean;
/** Releasing now would commit; drives `.onArm()` and `.onDisarm()`. */ /** Releasing now would commit; drives `.onArm()` and `.onDisarm()`. */
@@ -387,11 +486,37 @@ export class GestureRecognizer {
) {} ) {}
/** /**
* Whether this recognizer currently holds the touch, so native scrolling * Called for each `touchmove`: whether native scrolling must be cancelled.
* must not start: its session is open and has not ruled the touch out. *
* Only gestures that share an axis with scrolling take part. The browser
* sends its first cancelable `touchmove` after roughly 15–20px of movement,
* so the decision uses all of that movement: along this gesture's axis and
* direction claims the touch; clearly elsewhere lets it go to the browser
* (which then scrolls and sends `pointercancel`); unclear holds it for one
* more move.
*/ */
get intercepts(): boolean { claimTouch(): boolean {
return this.session !== null; const session = this.session;
if (!session || !sharesScroll(session.definition)) return false;
if (session.captured || session.scroll === "ours") return true;
const { distance, crossDistance } = session.tracker.displacement(
session.last,
);
const dominance =
session.definition.recognition.axisDominance ?? DEFAULT_AXIS_DOMINANCE;
const along = Math.abs(distance);
if (distance > 0 && along >= crossDistance * dominance) {
session.scroll = "ours";
return true;
}
if (
along >= crossDistance * dominance ||
crossDistance >= along * dominance
) {
this.session = null;
return false;
}
return true;
} }
pointerDown(event: PointerEvent): void { pointerDown(event: PointerEvent): void {
@@ -433,7 +558,8 @@ export class GestureRecognizer {
bounds, bounds,
), ),
), ),
axis: "pending", scroll: "pending",
last: { clientX: event.clientX, clientY: event.clientY },
captured: false, captured: false,
armed: false, armed: false,
operation: null, operation: null,
@@ -445,8 +571,8 @@ export class GestureRecognizer {
const session = this.sessionFor(event); const session = this.sessionFor(event);
if (!session) return; if (!session) return;
session.last = { clientX: event.clientX, clientY: event.clientY };
if (!session.captured) { if (!session.captured) {
if (!this.lockAxis(session, event)) return;
if (!GestureRecognizer.recognizes(session, event)) return; if (!GestureRecognizer.recognizes(session, event)) return;
session.captured = true; session.captured = true;
session.host.setPointerCapture?.(session.pointerId); session.host.setPointerCapture?.(session.pointerId);
@@ -537,35 +663,6 @@ export class GestureRecognizer {
await handle?.cancel(); await handle?.cancel();
} }
/**
* Decide, early in the movement, whether this touch is along the gesture's
* axis and direction. A touch that is clearly someone else's (the other
* axis, or the opposite way) closes the session, which hands it to native
* scrolling or to another recognizer.
*
* @returns Whether the session is still open.
*/
private lockAxis(session: PointerSession, event: PointerEvent): boolean {
if (session.axis === "ours") return true;
const { distance, crossDistance } = session.tracker.displacement(event);
const along = Math.abs(distance);
if (Math.max(along, crossDistance) < AXIS_LOCK_DISTANCE) return true;
const dominance =
session.definition.recognition.axisDominance ?? DEFAULT_AXIS_DOMINANCE;
if (along >= crossDistance * dominance && distance > 0) {
session.axis = "ours";
return true;
}
if (
along >= crossDistance * dominance ||
crossDistance >= along * dominance
) {
this.session = null;
return false;
}
return true; // Diagonal: keep watching.
}
/** Fire `.onArm()` / `.onDisarm()` when the release decision would flip. */ /** Fire `.onArm()` / `.onDisarm()` when the release decision would flip. */
private updateArming(session: PointerSession, event: PointerEvent) { private updateArming(session: PointerSession, event: PointerEvent) {
const { definition, tracker } = session; const { definition, tracker } = session;
@@ -691,24 +788,80 @@ export const useOriginGestures = (
return recognizers; return recognizers;
}; };
const style = computed(() => ({ // The host element, for scroll-edge state and selector scrollers.
width: "100%", const host = shallowRef<HTMLElement | null>(null);
height: "100%", // Bumped by scroll events; touch-action re-reads scroll edges on change.
// Preserve native scrolling on any axis no gesture claims. const scrolled = shallowRef(0);
touchAction: touchActionFor( const directional = supportsDirectionalPan();
toValue(definitions).map(({ direction }) => direction), const atEdge = (definition: Pick<OriginGestureDefinition, "start">) => {
), const { start } = definition;
})); if (start.kind !== "scroll-edge") return false;
const element = host.value;
if (!element && typeof start.scroller === "string") return true;
return isAtScrollEdge(start.scroller, start.edge, element!);
};
// A string, so hosts re-render only when touch-action actually changes,
// never merely because something scrolled.
const touchAction = computed(() => {
void scrolled.value;
return touchActionFor(toValue(definitions), atEdge, directional);
});
const sharesScrollAxis = computed(() =>
toValue(definitions).some(sharesScroll),
);
const style = computed(() => {
const value = touchAction.value;
return {
width: "100%",
height: "100%",
// Native scrolling stays on every pan no gesture needs.
touchAction: value,
// Inherited by descendants through the package stylesheet, so nested
// scroll containers (where browsers restart touch-action) keep it too.
"--nvo-touch-action": value,
};
});
// A recognizer whose component unmounts mid-gesture cancels its operation. // A recognizer whose component unmounts mid-gesture cancels its operation.
onScopeDispose(() => { onScopeDispose(() => {
for (const recognizer of recognizers.splice(0)) void recognizer.abandon(); for (const recognizer of recognizers.splice(0)) void recognizer.abandon();
}); });
// Only without directional touch-action do scroll-edge gestures need to
// claim touches from JavaScript, the one path that blocks scrolling.
const needsTouchmove = computed(() => sharesScrollAxis.value && !directional);
const onTouchmove = (event: TouchEvent) => {
if (!event.cancelable) return;
// Ask every recognizer: each one may let go of the touch as it answers.
let claimed = false;
for (const recognizer of current())
if (recognizer.claimTouch()) claimed = true;
if (claimed) event.preventDefault();
};
const onScrollCapture = () => {
scrolled.value += 1;
};
const listeners = computed(() => ({
...(sharesScrollAxis.value ? { onScrollCapture } : {}),
...(needsTouchmove.value ? { onTouchmove } : {}),
}));
return { return {
get style() { get style() {
return style.value; return style.value;
}, },
get needsTouchmove() {
return needsTouchmove.value;
},
get listeners() {
return listeners.value as Record<string, (event: Event) => void>;
},
ref: (element) => {
host.value = element instanceof HTMLElement ? element : null;
scrolled.value += 1;
},
onPointerdown: (event) => { onPointerdown: (event) => {
for (const recognizer of current()) recognizer.pointerDown(event); for (const recognizer of current()) recognizer.pointerDown(event);
}, },
@@ -725,12 +878,7 @@ export const useOriginGestures = (
for (const recognizer of current()) for (const recognizer of current())
void recognizer.pointerCaptureLost(event); void recognizer.pointerCaptureLost(event);
}, },
onTouchmove: (event) => { onTouchmove,
// Native scrolling starts at the first touchmove not cancelled, so keep
// cancelling while any recognizer still holds the touch.
if (event.cancelable && current().some(({ intercepts }) => intercepts))
event.preventDefault();
},
}; };
}; };
+17
View File
@@ -30,6 +30,23 @@
user-select: none; user-select: none;
} }
/*
* Browsers only combine touch-action up to the nearest scroll container, so a
* list scrolling inside a horizontal gesture host would otherwise let the
* browser take horizontal drags (and cancel the gesture). Each host publishes
* its touch-action as an inherited custom property; descendants adopt it.
* Zero specificity, so applications can still override any element.
*/
.nvo-gesture :where(*) {
touch-action: var(--nvo-touch-action);
}
/* Opted-out subtrees, such as horizontal chip rows, keep their own panning. */
.nvo-gesture :where([data-origin-gesture="ignore"]),
.nvo-gesture :where([data-origin-gesture="ignore"] *) {
touch-action: auto;
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.nvo-node { .nvo-node {
will-change: auto; will-change: auto;
+1 -1
View File
@@ -97,7 +97,7 @@ describe("gesture builder", () => {
const surface = root.querySelector("#multi-gesture-surface") as HTMLElement; const surface = root.querySelector("#multi-gesture-surface") as HTMLElement;
expect(surface.tagName).toBe("SECTION"); expect(surface.tagName).toBe("SECTION");
expect(surface.classList.contains("nvo-gesture")).toBe(true); expect(surface.classList.contains("nvo-gesture")).toBe(true);
expect(surface.style.touchAction).toBe("manipulation"); expect(surface.style.touchAction).toBe("none");
expect(surface.style.width).toBe("100%"); expect(surface.style.width).toBe("100%");
expect(surface.style.height).toBe("100%"); expect(surface.style.height).toBe("100%");
}); });
+125 -80
View File
@@ -6,7 +6,7 @@ import {
shallowRef, shallowRef,
type Component, type Component,
} from "vue"; } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { import {
allOf, allOf,
anyOf, anyOf,
@@ -104,68 +104,65 @@ const hostFor = (
), ),
}); });
describe("axis lock and native scrolling", () => { describe("recognition and native scrolling", () => {
const leftward = gesture.to.left().navigate(toTarget).animate(motion); const leftward = gesture.to.left().navigate(toTarget).animate(motion);
it("holds the touch while undecided or along its axis", async () => { it("leaves scrolling to the browser through touch-action alone", async () => {
const { scene, host } = await mount(hostFor(leftward)); const { scene, host } = await mount(hostFor(leftward));
// A horizontal gesture keeps native vertical panning.
expect(host.style.touchAction).toBe("pan-y");
host.dispatchEvent(pointer("pointerdown", 150, 100, 0)); host.dispatchEvent(pointer("pointerdown", 150, 100, 0));
const undecided = touchmove();
host.dispatchEvent(undecided);
expect(undecided.defaultPrevented).toBe(true);
host.dispatchEvent(pointer("pointermove", 140, 101, 16)); host.dispatchEvent(pointer("pointermove", 140, 101, 16));
await flush(); await flush();
const along = touchmove();
host.dispatchEvent(along);
expect(along.defaultPrevented).toBe(true);
expect(scene.operations.value).toHaveLength(1);
});
it("lets go of a touch moving along the other axis", async () => {
const { scene, host } = await mount(hostFor(leftward));
host.dispatchEvent(pointer("pointerdown", 150, 100, 0));
host.dispatchEvent(pointer("pointermove", 149, 110, 16));
const vertical = touchmove();
host.dispatchEvent(vertical);
expect(vertical.defaultPrevented).toBe(false);
// Later horizontal movement no longer starts the gesture.
host.dispatchEvent(pointer("pointermove", 90, 115, 32));
await flush();
expect(scene.operations.value).toHaveLength(0);
});
it("lets go of a touch moving the opposite way", async () => {
const { scene, host } = await mount(hostFor(leftward));
host.dispatchEvent(pointer("pointerdown", 100, 100, 0));
host.dispatchEvent(pointer("pointermove", 110, 100, 16));
const opposite = touchmove();
host.dispatchEvent(opposite);
expect(opposite.defaultPrevented).toBe(false);
expect(scene.operations.value).toHaveLength(0);
});
it("hands a touch between gestures sharing one host", async () => {
const down = gesture.to.down().navigate(toTarget).animate(motion);
const Root = defineComponent({
setup: () => () =>
h(
OriginGestureSurface,
{ id: "host", gestures: [leftward, down] },
() => "x",
),
});
const { scene, host } = await mount(Root);
expect(host.style.touchAction).toBe("manipulation");
host.dispatchEvent(pointer("pointerdown", 100, 100, 0));
host.dispatchEvent(pointer("pointermove", 101, 140, 16));
await flush();
// The downward gesture took it; the leftward one let go.
expect(scene.operations.value).toHaveLength(1);
const move = touchmove(); const move = touchmove();
host.dispatchEvent(move); host.dispatchEvent(move);
expect(move.defaultPrevented).toBe(true); // Ordinary gestures never block touchmove, so the browser never waits.
expect(move.defaultPrevented).toBe(false);
expect(scene.operations.value).toHaveLength(1);
});
it("recognizes a swipe that starts with a vertical wobble", async () => {
const { scene, host } = await mount(hostFor(leftward));
host.dispatchEvent(pointer("pointerdown", 150, 100, 0));
host.dispatchEvent(pointer("pointermove", 149, 104, 16));
host.dispatchEvent(pointer("pointermove", 148, 107, 32));
host.dispatchEvent(pointer("pointermove", 120, 108, 48));
await flush();
expect(scene.operations.value).toHaveLength(1);
});
it("recognizes a swipe that starts with a wind-up the wrong way", async () => {
const { scene, host } = await mount(hostFor(leftward));
host.dispatchEvent(pointer("pointerdown", 150, 100, 0));
host.dispatchEvent(pointer("pointermove", 153, 100, 16));
host.dispatchEvent(pointer("pointermove", 156, 100, 32));
host.dispatchEvent(pointer("pointermove", 120, 100, 48));
await flush();
expect(scene.operations.value).toHaveLength(1);
});
it("claims touch-action per axis like native scrolling expects", async () => {
const down = gesture.to.down().navigate(toTarget).animate(motion);
const scroller = shallowRef<HTMLElement | null>(null);
const dismiss = gesture.from
.scrollEdge(scroller, "top")
.to.down()
.navigate(toTarget)
.animate(motion);
const surface = (gestures: OriginGestureDefinition[]) =>
defineComponent({
setup: () => () =>
h(OriginGestureSurface, { id: "host", gestures }, () => "x"),
});
expect(
(await mount(surface([leftward, down]))).host.style.touchAction,
).toBe("none");
document.body.innerHTML = "";
// A scroll-edge gesture at its edge reserves only its own pan: with the
// horizontal gesture claiming pan-x, only scrolling down remains.
expect(
(await mount(surface([leftward, dismiss]))).host.style.touchAction,
).toBe("pan-down");
}); });
}); });
@@ -185,36 +182,84 @@ describe("scroll edges", () => {
}), }),
); );
const mounted = await mount(Root); const mounted = await mount(Root);
Object.defineProperty(scroller.value!, "scrollTop", { value: scrollTop }); let top = scrollTop;
return mounted; Object.defineProperty(scroller.value!, "scrollTop", {
get: () => top,
configurable: true,
});
const scrollTo = async (value: number) => {
top = value;
scroller.value!.dispatchEvent(new Event("scroll"));
await nextTick();
};
await scrollTo(scrollTop);
return { ...mounted, scrollTo };
} }
it("starts a downward drag only when the scroller is at the top", async () => { describe("with directional touch-action", () => {
const atTop = await mountWithScroller(0); it("reserves only the dismiss pan while the list is at the top", async () => {
atTop.host.dispatchEvent(pointer("pointerdown", 100, 100, 0)); const { host, scrollTo } = await mountWithScroller(0);
atTop.host.dispatchEvent(pointer("pointermove", 100, 140, 16)); // Dragging up still scrolls natively; dragging down is the gesture's.
await flush(); expect(host.style.touchAction).toBe("pan-x pan-down");
expect(atTop.scene.operations.value).toHaveLength(1); await scrollTo(120);
expect(host.style.touchAction).toBe("manipulation");
await scrollTo(0);
expect(host.style.touchAction).toBe("pan-x pan-down");
});
it("never blocks touchmove", async () => {
const { host } = await mountWithScroller(0);
host.dispatchEvent(pointer("pointerdown", 100, 100, 0));
host.dispatchEvent(pointer("pointermove", 100, 118, 16));
const move = touchmove();
host.dispatchEvent(move);
expect(move.defaultPrevented).toBe(false);
});
it("starts a downward drag only when the scroller is at the top", async () => {
const atTop = await mountWithScroller(0);
atTop.host.dispatchEvent(pointer("pointerdown", 100, 100, 0));
atTop.host.dispatchEvent(pointer("pointermove", 100, 140, 16));
await flush();
expect(atTop.scene.operations.value).toHaveLength(1);
});
it("leaves a scrolled list to scroll", async () => {
const scrolled = await mountWithScroller(120);
scrolled.host.dispatchEvent(pointer("pointerdown", 100, 100, 0));
scrolled.host.dispatchEvent(pointer("pointermove", 100, 140, 16));
await flush();
expect(scrolled.scene.operations.value).toHaveLength(0);
});
}); });
it("leaves a scrolled list to scroll", async () => { describe("without directional touch-action (Safari)", () => {
const scrolled = await mountWithScroller(120); beforeEach(() => {
scrolled.host.dispatchEvent(pointer("pointerdown", 100, 100, 0)); vi.stubGlobal("CSS", { supports: () => false });
const move = touchmove(); });
scrolled.host.dispatchEvent(move);
expect(move.defaultPrevented).toBe(false);
scrolled.host.dispatchEvent(pointer("pointermove", 100, 140, 16));
await flush();
expect(scrolled.scene.operations.value).toHaveLength(0);
});
it("at the top, still lets an upward drag scroll the list", async () => { it("keeps the axis pannable and claims touches from JavaScript", async () => {
const atTop = await mountWithScroller(0); const atTop = await mountWithScroller(0);
atTop.host.dispatchEvent(pointer("pointerdown", 100, 200, 0)); expect(atTop.host.style.touchAction).toBe("manipulation");
atTop.host.dispatchEvent(pointer("pointermove", 100, 180, 16)); atTop.host.dispatchEvent(pointer("pointerdown", 100, 100, 0));
const move = touchmove(); // The browser's first cancelable touchmove arrives after its slop.
atTop.host.dispatchEvent(move); atTop.host.dispatchEvent(pointer("pointermove", 101, 118, 16));
expect(move.defaultPrevented).toBe(false); const first = touchmove();
atTop.host.dispatchEvent(first);
expect(first.defaultPrevented).toBe(true);
atTop.host.dispatchEvent(pointer("pointermove", 100, 140, 32));
await flush();
expect(atTop.scene.operations.value).toHaveLength(1);
});
it("lets an upward drag at the top scroll the list", async () => {
const atTop = await mountWithScroller(0);
atTop.host.dispatchEvent(pointer("pointerdown", 100, 200, 0));
atTop.host.dispatchEvent(pointer("pointermove", 100, 180, 16));
const move = touchmove();
atTop.host.dispatchEvent(move);
expect(move.defaultPrevented).toBe(false);
});
}); });
}); });
+3 -3
View File
@@ -241,7 +241,7 @@ describe("reactive gesture props", () => {
const rightward = gesture.to.right().navigate(toTarget).animate(motion); const rightward = gesture.to.right().navigate(toTarget).animate(motion);
const current = shallowRef(leftward); const current = shallowRef(leftward);
const { scene, host } = await mount(gestureHost(() => current.value)); const { scene, host } = await mount(gestureHost(() => current.value));
expect(host.style.touchAction).toBe("manipulation"); expect(host.style.touchAction).toBe("pan-y");
// Swap mid-gesture: the drag in progress keeps the leftward definition. // Swap mid-gesture: the drag in progress keeps the leftward definition.
host.dispatchEvent(pointer("pointerdown", 180, 0)); host.dispatchEvent(pointer("pointerdown", 180, 0));
@@ -295,14 +295,14 @@ describe("reactive gesture props", () => {
), ),
}); });
const { scene, host } = await mount(Root); const { scene, host } = await mount(Root);
expect(host.style.touchAction).toBe("manipulation"); expect(host.style.touchAction).toBe("pan-y");
list.value = [ list.value = [
...list.value, ...list.value,
gesture.to.down().navigate(toTarget).animate(motion), gesture.to.down().navigate(toTarget).animate(motion),
]; ];
await nextTick(); await nextTick();
expect(host.style.touchAction).toBe("manipulation"); expect(host.style.touchAction).toBe("none");
host.dispatchEvent( host.dispatchEvent(
Object.assign(pointer("pointerdown", 100, 0), { clientY: 0 }), Object.assign(pointer("pointerdown", 100, 0), { clientY: 0 }),
+13 -9
View File
@@ -19,19 +19,23 @@ Behavioural verification lives with the unit tests: `tests/property.test.ts`
drives random operation sequences and pointer streams and checks the scene's drives random operation sequences and pointer streams and checks the scene's
structural invariants after every step. Set `PROPERTY_SEEDS` to run more. structural invariants after every step. Set `PROPERTY_SEEDS` to run more.
## Real-browser touch checks ## Real-browser feel tests
`browser/` drives a development build in Chromium with emulated touch input, `browser/` drives a development build in Chromium with emulated touch input,
which happy-dom cannot model: implicit touch pointer capture, native scrolling, which happy-dom cannot model: implicit touch pointer capture, native
`pointercancel`, and rendered styles. It checks that swipes over scrollers scrolling, `pointercancel`, frame timing, and rendered styles. It checks:
commit, that scrolling still works beside gestures, scroll-edge dismissal,
that `onCommit` runs inside `pointerup`, and the development continuity - **recognition** on human-like traces: wobbly and diagonal starts, a small
warning. wind-up the wrong way, a twitch at lift-off, slow and short drags, flicks,
and vertical scrolls that must not trigger gestures;
- **frame pacing** while a finger drags (p95 ≤ 20ms, ≤ 5% of frames over
20ms) for a horizontal swipe and a scroll-edge dismiss;
- **behaviour**: swipes over nested scrollers, native scrolling beside
gestures, scroll-edge dismissal, `onCommit` inside `pointerup`, and the
development continuity warning.
```sh ```sh
NODE_ENV=development npx vite build --mode development \ npm run test:feel
--config packages/core-v2/verify/browser/app/vite.config.mjs
node packages/core-v2/verify/browser/run.mjs
``` ```
Set `CHROMIUM_PATH` to use a Chromium other than Playwright's own. Set `CHROMIUM_PATH` to use a Chromium other than Playwright's own.
@@ -1,4 +1,5 @@
import { createApp, defineComponent, h, ref } from "vue"; import { createApp, defineComponent, h, ref } from "vue";
import "../../../src/style.css";
import { import {
OriginGesture, OriginGesture,
OriginGestureSurface, OriginGestureSurface,
+67
View File
@@ -165,6 +165,73 @@ const log = (page) => page.evaluate(() => window.log.slice());
); );
await t.page.close(); await t.page.close();
} }
// 5. Recognition feel: human-like traces on the Home swipe (over a nested
// scroller). Real fingers rarely start cleanly; every trace that should open
// must open, and the rest must not.
{
const steady = (total, steps, ms = 16) => Array.from({ length: steps }, () => [total / steps, 0, ms]);
const traces = [
["clean swipe", steady(-200, 12), true],
["short steady drag (31%)", steady(-120, 10), true],
["wobbly start, then swipe", [[-1, 4, 16], [-1, 3, 16], [-1, 2, 16], ...steady(-200, 12)], true],
["wind-up 6px the wrong way", [[3, 0, 16], [3, 0, 16], ...steady(-206, 12)], true],
["diagonal first 10px", [[-5, -5, 16], [-5, -4, 16], ...steady(-190, 11)], true],
["backward twitch at lift-off", [...steady(-200, 12), [6, 0, 12]], true],
["slow drag to 50%, hold, release", [...steady(-195, 40), [0, 0, 120]], true],
["slow drag to 20%, hold, release", [...steady(-80, 20), [0, 0, 120]], false],
["quick flick (15%)", steady(-60, 4, 10), true],
["vertical scroll", Array.from({ length: 12 }, () => [1, -25, 16]), false],
];
for (const [name, steps, shouldOpen] of traces) {
const t = await open();
let [x, y] = [330, 400];
await t.cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
for (const [dx, dy, ms] of steps) {
x += dx; y += dy;
if (dx || dy) await t.cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x, y }] });
await t.page.waitForTimeout(ms);
}
await t.cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
await t.page.waitForTimeout(800);
const opened = (await states(t.page)) === "root:parked detail:active";
check(`recognition: ${name} ${shouldOpen ? "opens" : "stays"}`, opened === shouldOpen, opened ? "opened" : "stayed");
await t.page.close();
}
}
// 6. Frame pacing while a finger drags: no dropped frames from blocking
// touch handling. Regressions measured p95 ≈ 28ms and ~30% of frames > 20ms.
async function pacing(query, setup, from, to) {
const gaps = [];
for (let run = 0; run < 6; run++) {
const t = await open(query);
await setup(t);
await t.page.evaluate(() => { window.f = []; const loop = () => { window.f.push(performance.now()); window.r = requestAnimationFrame(loop); }; window.r = requestAnimationFrame(loop); });
await t.cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x: from[0], y: from[1] }] });
for (let i = 1; i <= 40; i++) {
await t.cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x: from[0] + ((to[0] - from[0]) * i) / 40, y: from[1] + ((to[1] - from[1]) * i) / 40 }] });
await t.page.waitForTimeout(16);
}
const f = await t.page.evaluate(() => { cancelAnimationFrame(window.r); return window.f; });
await t.cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
gaps.push(...f.slice(1).map((v, i) => v - f[i]));
await t.page.close();
}
gaps.sort((a, b) => a - b);
return { p95: gaps[Math.floor(0.95 * (gaps.length - 1))], over20: gaps.filter((g) => g > 20).length / gaps.length };
}
const openDetail = async (t) => { await drag(t, [340, 400], [60, 405]); };
for (const [name, result] of [
["horizontal swipe drag", await pacing("", async () => {}, [340, 300], [40, 300])],
["scroll-edge dismiss drag", await pacing("", openDetail, [200, 200], [200, 600])],
]) {
check(
`pacing: ${name} p95 ≤ 20ms and ≤ 5% of frames over 20ms`,
result.p95 <= 20 && result.over20 <= 0.05,
`p95=${result.p95.toFixed(1)}ms over20=${(result.over20 * 100).toFixed(1)}%`,
);
}
// 4. Continuity warning. // 4. Continuity warning.
for (const [name, expectWarning] of [ for (const [name, expectWarning] of [
["matched", false], ["matched", false],