Author SHA1 Message Date
Claude caf2ad692a 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
2026-11-03 05:56:26 +00:00
Claude 6889fc0112 Make gestures cooperate with scrolling and feel smoother
From an app built on core-v2:

Scrolling (#3, #4, #14)
- Each recognizer locks onto a touch within its first 6px: along its axis
  and direction it holds the touch, otherwise it lets go. A new
  onTouchmove binding cancels native scrolling only while a recognizer
  holds the touch, so hosts use touch-action: manipulation and one
  vertical gesture no longer disables vertical scrolling. Several
  gestures on one host share touches through the same rule.
- .from.scrollEdge(scroller, edge) admits drag-to-dismiss only while a
  scroller is at the edge (#5).

Feel
- recognition.distance sets what counts as full progress: pixels, a CSS
  length, or a measuring function (#6).
- Choreographies receive overshoot, the drag past either end of the
  clamped progress, and the settling spring starts from it (#7).
- Velocity is the least-squares trend of the last 100ms (#8).
- Defaults retuned to commitThreshold 0.25 and commitVelocity 0.2; the
  slide presets use them (#9).
- Choreographies may set spring stiffness and damping (#10).

Hooks and helpers
- .onArm()/.onDisarm() run during a drag when releasing would start or
  stop committing (#12).
- startedIn, nearEdge, atScrollEdge, anyOf, allOf start predicates (#15).
- Development builds warn when an operation's first frame moves a node
  that was visibly at rest, the one-frame "pop" of mismatched open and
  close choreographies (#16).

Docs: scrolling, ignored elements, animation performance, haptics, and
operating-system edge gestures including CloseWatcher/popstate for PWAs
(#11, #17, #18).

Verification: a Chromium harness with emulated touch (verify/browser)
checks swipes over scrollers, native scrolling beside gestures,
scroll-edge dismissal, onCommit inside pointerup, and the continuity
warning. Reverting the earlier capture fix makes it fail exactly as
reported ("none of the handles work").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
2026-10-31 08:22:11 +00:00
Claude d4d7feb3b7 Fix touch gestures cancelling, backward flicks, and late onCommit
- Only the host's own lostpointercapture cancels a gesture. Taking
  capture makes the touched child lose its implicit touch capture, and
  that bubbling event was cancelling every touch gesture immediately.
- A flick at least commitVelocity fast now decides by its direction:
  backward cancels even past the distance threshold, forward commits.
  Slower releases still decide by distance.
- Pointer-up finishes an already-begun operation without awaiting, so
  onCommit runs inside the pointer-up event, where iOS still allows
  haptics, audio, and clipboard writes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
2026-10-31 05:41:51 +00:00
Claude 7033814550 Trim per-release overhead in the scene
Only re-measure the viewport at begin() when a container is mounted, and
only allocate waiting machinery when an interrupted finish() actually
needs to wait for a later release. Interrupted releases now resolve with
the final decided outcome in every case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
2026-10-16 04:28:43 +00:00
Claude a46c411ec4 Move core-v2 tests into packages/core-v2/tests
Tests no longer sit next to the implementation. They import the package
through ../src, the root tsconfig still type-checks them, and the
package build and declarations cover only src/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
2026-10-16 04:24:47 +00:00
Claude 1924d4b52b Close a revealed page's open drawer when its back is cancelled
discardOutgoing skipped finished resting edges, so a persistAtRest drawer
opened by a node revealed during a back operation stayed attached after
that back was cancelled and the node returned to history. Found by the
property tests (1000-seed run).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
2026-10-16 04:10:53 +00:00
Claude b6f782d8af Fix stuck gestures and scene graph races; honour commitVelocity
Gestures
- Cancel a captured gesture on lostpointercapture, on a new primary
  pointer-down, and when its component unmounts, so operations are
  never left stuck mid-transition. Bindings gain onLostpointercapture.
- Warn (console.warn) when navigate() fails or the scene rejects an
  operation; stay quiet when two recognizers race for one origin.
- OriginGesture and OriginGestureSurface props are reactive. Changes
  apply from the next pointer-down; recognizers are kept per list
  position so re-renders never interrupt a drag.
- Remove the legacy direction/edge/threshold/action gesture form
  (OriginGestureOptions, legacy OriginGesture props, MaybeOriginAction).

Scene
- Honour choreography commitVelocity (default 0.3, as before).
- Scene errors are OriginSceneError with a stable code.
- update() during settling now really catches an operation: the spring
  stops, and only the latest finish() decides the outcome.
- Fix graph corruption found by the new property tests, all present on
  main: replacements escaping a cancelled push, edges left dangling when
  their current target differs from the original one, revealed targets
  keeping operations after their back is cancelled, history pointing at
  removed entries after replace, visual cycles from back through an
  undecided replace, exposed nodes reactivated as active, and a cancel
  cascade that could empty the whole scene.

Tests
- Add property tests checking structural invariants after every step of
  random operation sequences and pointer streams (PROPERTY_SEEDS).
- Add recognizer tests for stuck gestures, warnings, reactive props,
  and commitVelocity. Drop the main-comparison suites; keep benchmarks.

Also fix the origins demo's two pre-existing type errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
2026-10-16 04:09:57 +00:00
Claude 46f030630b Speed up compositing and gesture start without behavior changes
- Memoize each operation's choreography effect set in a computed, so
  nodes sharing an edge stop re-evaluating it (linear, not quadratic,
  in chain depth).
- Cache the scene viewport and refresh it from a ResizeObserver and at
  each begin(), instead of measuring the container per node per frame.
- Index operations by origin key so outgoing lookups no longer scan
  every operation; all map changes go through attach()/detach().
- Resolve px and % gesture edge distances arithmetically; only other
  CSS lengths use the off-screen probe. Verified in Chromium to match
  the probe within its 1/64px layout rounding.

The differential suites against main still match exactly. Benchmarks
vs main: styleForNode 2.0x (3 live ops) and 2.4x (10 live ops),
rendered frames 1.14x.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
2026-10-16 03:26:51 +00:00
34 changed files with 3462 additions and 1355 deletions
@@ -7,6 +7,7 @@ import {
gesture,
originView,
useOrigin,
type OriginView,
} from "@native-vue-router/core-v2";
import { useDemoInstance } from "../lab-state";
import { unfocusPortal } from "../motions";
@@ -86,7 +87,7 @@ const slideSets: Record<NestedSceneKind, readonly SlideCopy[]> = {
],
};
function nestedView(kind: NestedSceneKind, index: number) {
function nestedView(kind: NestedSceneKind, index: number): OriginView {
const slides = slideSets[kind];
const copy = slides[index]!;
return originView(
@@ -98,7 +99,7 @@ function nestedView(kind: NestedSceneKind, index: number) {
...copy,
next:
index + 1 < slides.length
? () => nestedView(kind, index + 1)
? (): OriginView => nestedView(kind, index + 1)
: undefined,
},
{
+2 -1
View File
@@ -49,7 +49,8 @@
"electron": "npm run build && electron apps/electron/main.mjs",
"cap:sync": "npm run build && npm --prefix apps/capacitor exec cap sync",
"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": {
"@capacitor/app": "^8.0.0",
+308 -40
View File
@@ -20,6 +20,7 @@ This document describes the experimental `0.1.0-experimental.0` API.
- [Scene diagnostics and manual operations](#scene-diagnostics-and-manual-operations)
- [Type reference](#type-reference)
- [Errors and constraints](#errors-and-constraints)
- [Gestures in real apps](#gestures-in-real-apps)
## Gesture start recognition
@@ -43,6 +44,7 @@ gesture.from.top(distance);
gesture.from.bottom(distance);
gesture.from.anywhere();
gesture.from.when(predicate);
gesture.from.scrollEdge(scroller, edge);
```
`distance` accepts a number in CSS pixels or a CSS length string, including
@@ -80,9 +82,43 @@ const dropDialog = gesture.from
.animate(dropAnimation);
```
Interactive form controls are ignored automatically. Add
`.from.scrollEdge(scroller, edge)` admits pointer-down only while a scroll
container is scrolled all the way to `edge`. It is the drag-down-to-dismiss
rule for sheets and pushed pages: at the top, dragging down dismisses and
dragging up still scrolls; anywhere else the list just scrolls. `scroller` is
an element, a ref or getter returning one, or a CSS selector resolved inside
the gesture host. A scroller that is not mounted counts as at the edge.
```ts
const list = ref<HTMLElement | null>(null);
const dismiss = gesture.from
.scrollEdge(list, "top")
.to.down()
.navigate((context) => (context.canGoBack ? back() : null))
.animate(sheetDown);
```
Start rules also come as composable predicates for `.from.when()`:
| Helper | Admits pointer-down |
| ------------------------------ | ------------------------------------------- |
| `startedIn(selector)` | On or inside an element matching `selector` |
| `nearEdge(edge, distance)` | Within `distance` of one side of the host |
| `atScrollEdge(scroller, edge)` | While `scroller` is scrolled to `edge` |
| `anyOf(...predicates)` | When any predicate does |
| `allOf(...predicates)` | When every predicate does |
```ts
gesture.from
.when(anyOf(startedIn(".sheet-handle"), atScrollEdge(list, "top")))
.to.down();
```
Interactive form controls (`input`, `textarea`, `select`, `option`, and
`[contenteditable="true"]`) are ignored automatically. Add
`data-origin-gesture="ignore"` to any other element or ancestor that should not
begin a gesture.
begin a gesture, such as a horizontally scrolling chip row inside a host with a
horizontal gesture.
## Minimum setup
@@ -145,7 +181,7 @@ recognizer to it.
| Prop | Type | Default | Description |
| --------- | ------------------------- | ------- | ---------------------------------- |
| `as` | `string` | `"div"` | HTML tag used for the gesture host |
| `gesture` | `OriginGestureDefinition` | — | Preferred builder definition |
| `gesture` | `OriginGestureDefinition` | — | Builder definition |
Attributes, classes, and listeners not consumed as props are forwarded to the
rendered host.
@@ -156,8 +192,9 @@ rendered host.
</OriginGesture>
```
For compatibility, the component also accepts the legacy mutually exclusive
set of `direction`, `edge`, `threshold`, and `action` props.
The `gesture` prop is reactive. A new definition applies from the next
pointer-down; a gesture already in progress keeps the definition it started
with, so a parent re-render never interrupts a drag.
Use `useOriginGesture()` instead when an extra wrapper is undesirable.
@@ -183,16 +220,15 @@ const gestures = [forwardGesture, backGesture] as const;
| `gestures` | `readonly OriginGestureDefinition[]` | required | Fully defined page-owned rules |
The component adds no recognition or navigation policy. It installs each
definition with `useOriginGesture()`, forwards every pointer event to every
binding, and derives the least-permissive shared `touch-action`:
definition, forwards every pointer event to every recognizer, and derives one
`touch-action` that leaves native scrolling on every pan no gesture needs. A
horizontal drawer and a scroll-edge pull-down can share a host with vertical
scrolling. See [Scrolling](#scrolling).
- horizontal only: `pan-y`;
- vertical only: `pan-x`;
- both axes: `none`.
Definitions should be immutable and stable for the lifetime of the rendered
surface. The owning page remains the visible declaration point for every
`.from`, `.to`, `.complete`, `.navigate`, and `.animate` choice.
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
from the next pointer-down. The owning page remains the visible declaration
point for every `.from`, `.to`, `.complete`, `.navigate`, and `.animate` choice.
## Nested scenes
@@ -424,6 +460,16 @@ change a definition already produced from it.
| --------------- | ------- | ------------------------------------------------- |
| `threshold` | `8` | Directed CSS pixels required before capture |
| `axisDominance` | `1.15` | Directed/cross-axis ratio required before capture |
| `distance` | host | Movement that counts as full progress |
`distance` is resolved at pointer-down: a number in pixels, a CSS length
(percentages are of the host), or a function `({ host, bounds }) => pixels`.
Set it when the moving element is smaller than the host, such as a drawer 84%
of the screen wide, so the drawer stays under the finger:
```ts
gesture.to.right({ distance: () => drawer.value?.offsetWidth ?? 300 });
```
If `.complete()` is omitted, the choreography's `commitThreshold` and
`commitVelocity` decide release normally.
@@ -449,15 +495,36 @@ const openProfile = gesture.to
.animate(slideLeft);
```
`navigator.vibrate` is unavailable in iOS Safari; Capacitor apps can call
`Haptics.impact()` from `@capacitor/haptics` instead. The method is named
`onCommit` rather than `then` so builder stages never become promise-like
"thenables" when awaited or returned from an async function.
Handlers run inside the `pointerup` event whenever the operation has already
begun, which is the usual case, so APIs that need the user's gesture (iOS
haptics, audio, clipboard writes) work there. See [Haptics](#haptics).
The method is named `onCommit` rather than `then` so builder stages never
become promise-like "thenables" when awaited or returned from an async
function.
`.onArm(handler)` and `.onDisarm(handler)` follow the same rules and run during
the drag: `onArm` when releasing would start to commit, `onDisarm` when it no
longer would. Use them for a "release now" cue or haptic. Arming follows
distance (`progress >= commitThreshold`) because velocity changes too quickly to
drive a cue without flickering; when the definition has a `.complete()`
predicate, that predicate decides arming instead.
```ts
gesture.to
.left()
.navigate(() => forward(originView(Archive)))
.onArm(() => haptic("light"))
.onDisarm(() => haptic("soft"))
.onCommit(() => haptic("medium"))
.animate(slideLeft);
```
### `useOriginGesture(definition)`
Creates one primary-pointer, single-axis recognizer owned by the component that
calls it.
calls it. `definition` may also be a ref or getter; a changed definition
applies from the next pointer-down.
```ts
const open = useOriginGesture(
@@ -477,28 +544,63 @@ interface OriginGestureBinding {
readonly onPointermove: (event: PointerEvent) => void;
readonly onPointerup: (event: PointerEvent) => void;
readonly onPointercancel: () => void;
readonly onLostpointercapture: (event: PointerEvent) => void;
readonly listeners: Readonly<Record<string, (event: Event) => void>>;
readonly ref: (element: unknown) => void;
}
```
Apply all handlers to the same element. The returned style sets dimensions and
`touch-action` so native scrolling remains available on the cross-axis.
Apply the pointer handlers, the returned style, and `listeners` to the same
element, and pass `ref` as its function ref:
```vue
<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:
1. A primary, left-button pointer satisfies the optional start policy.
2. The target is not an ignored interactive element.
3. Directed movement reaches `threshold`.
3. Directed movement from the pointer-down point reaches `threshold`.
4. Directed movement exceeds cross-axis movement by `axisDominance`.
5. The navigation factory returns an intent.
Progress is directed distance divided by host width or height. Release velocity
is normalized by the same dimension.
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
the host's width or height). Past either end, the excess reaches
choreographies as `overshoot`. Release velocity is normalized by the same
distance and measured as the trend of the last 100ms of samples, so uneven
touch timing does not distort it and a pointer held still before release has
no flick.
An asynchronous navigation factory is supported. If it resolves after the
pointer was released or cancelled, the stale result is discarded.
The legacy `OriginGestureOptions` object remains accepted. Its `edge` is a
number inferred from the side opposite `direction`, matching the previous API.
A captured gesture is cancelled, never left mid-transition, when its pointer
ends without a pointer-up: on `pointercancel`, on `lostpointercapture`, when a
new primary pointer goes down, or when the component unmounts.
Failures are reported with `console.warn` and the gesture is abandoned: a
`navigate()` factory that throws or rejects, or a scene that rejects the
operation (see `OriginSceneError`). Returning `null` declines silently, as
does losing a race to another gesture on the same origin.
## Choreographies and effects
@@ -571,14 +673,18 @@ can run repeatedly during rendering and animation.
### Commit thresholds
When `finish()` does not explicitly override the decision, a target commits
when either:
When `finish()` does not explicitly override the decision, the release velocity
decides first, then distance:
- `progress >= commitThreshold`, default `0.36`; or
- release `velocity >= 0.3` progress units per second, at any progress.
- a flick at least `commitVelocity` fast (default `0.2` progress units per
second, about 80px/s across a 390px phone) decides by its direction: forward
commits, backward cancels, regardless of progress;
- a slower release commits when `progress >= commitThreshold`, default `0.25`.
`commitVelocity` is accepted on choreographies but is not currently consulted;
the flick threshold is the fixed `0.3` above.
The included presets use these defaults.
Velocity is normalized by the gesture host's size, so `1` is one full width (or
height) per second.
The operation's intent becomes final before its spring settles.
@@ -662,8 +768,13 @@ can immediately begin its own outgoing operation, enabling X → Y → Z chains.
### `OriginOperationHandle`
`intent` reports the selected outcome (`undecided`, `commit`, or `cancel`). It
changes synchronously when `finish()` or `cancel()` is called.
`intent` changes synchronously when `finish()` or `cancel()` is called, before
the settling spring runs.
Calling `update()` while an operation settles catches it: the spring stops and
progress is under control again, for example to grab a transition that is
still animating. A later `finish()` decides the outcome, and any earlier
`finish()` promise resolves with that final outcome.
| Member | Description |
| ----------------------------- | --------------------------------------------------- |
@@ -672,6 +783,7 @@ changes synchronously when `finish()` or `cancel()` is called.
| `targetKey` | Created or retained target node key |
| `update(progress, velocity?)` | Update normalized interactive state |
| `finish(options?)` | Decide, settle, and return whether target committed |
| `intent` | Selected outcome: `undecided`, `commit`, `cancel` |
| `cancel(options?)` | Force cancellation and remove the target branch |
`OriginFinishOptions`:
@@ -737,12 +849,6 @@ Node-local action context:
- `previous`: recipe belonging to the mounted previous entry.
- `history`: recipes belonging to all retained previous entries.
### `MaybeOriginAction`
```ts
OriginAction | null | undefined | Promise<OriginAction | null | undefined>;
```
### `OriginEffect`
| Field | Description |
@@ -765,6 +871,21 @@ OriginAction | null | undefined | Promise<OriginAction | null | undefined>;
| `originRect` | Origin bounds captured before target mounting |
| `targetRect` | Target bounds measured after mounting |
| `viewport` | Scene-container bounds, with browser viewport fallback |
| `overshoot` | Drag distance past either end of `progress`, else `0` |
`overshoot` is negative when a drag is pulled back beyond the start and
positive beyond full progress. Use it for rubber-banding:
```ts
effects: ({ progress, overshoot }) => ({
target: {
transform: `translateX(${(1 - progress) * 100 - overshoot * 20}%)`,
},
});
```
The settling spring starts from the overshot position, so a rubber-banded drag
springs back continuously.
Rect values are viewport CSS pixels.
@@ -786,6 +907,21 @@ implementation types. They are exported by the current barrel but marked
## Errors and constraints
Scene operations reject with `OriginSceneError`, whose `code` identifies the
cause:
| Code | Cause |
| ------------------- | --------------------------------------------------- |
| `missing-node` | The node no longer exists |
| `missing-origin` | The origin node does not exist |
| `inert-origin` | The origin is parked or exposed |
| `origin-busy` | The origin already has a live outgoing operation |
| `invalid-action` | The action cannot be executed as written |
| `no-previous-entry` | Back was requested without a retained entry |
| `undecided-forward` | Back was requested through an unreleased forward |
| `target-busy` | The back target is transitioning or still presented |
| `outside-scene` | `useOrigin()` was called outside an `<OriginScene>` |
- `useOrigin()` and `useOriginGesture()` must run inside a component mounted by
`OriginScene`.
- An origin can own only one outgoing operation at a time.
@@ -803,3 +939,135 @@ implementation types. They are exported by the current barrel but marked
- A choreography creates one target. Chaining supports any number of
simultaneously mounted targets.
- Reduced-motion preference resolves settling immediately.
## Gestures in real apps
### Scrolling
Native scrolling stays in the browser's hands: gesture hosts never make it
wait for JavaScript. Each host gets a `touch-action` that keeps every pan no
gesture needs:
| Gestures on the host | `touch-action` |
| --------------------------------------------------------------- | ---------------- |
| Horizontal only | `pan-y` |
| Vertical only | `pan-x` |
| Both axes | `none` |
| `.from.scrollEdge(list, "top")` + `.to.down()`, list at the top | `pan-x pan-down` |
| The same, list scrolled | `manipulation` |
The browser therefore scrolls the free axis natively from the first frame,
and touches on a gesture's axis reach the recognizer untouched.
A gesture that shares its axis with a scroller, such as drag-down-to-dismiss
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
Pointer-down on `input`, `textarea`, `select`, `option`, or
`[contenteditable="true"]` never starts a gesture, so text selection and
form controls keep working. Mark anything else that must keep its own drags
with `data-origin-gesture="ignore"`, for example a horizontally scrolling chip
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
Animate `transform` and `opacity` only. Browsers composite them without
layout or paint. Animating `filter`, `clip-path`, `box-shadow`, or
`border-radius` through `effect.style` repaints every frame and is the usual
cause of janky transitions; if you need one of them, set it once at rest
rather than per frame.
### Continuity between choreographies
In development builds the scene warns when an operation's first frame moves a
node that was visibly at rest. This catches the one-frame "pop" when a close
choreography's progress-0 effects do not match the pose the previous
choreography left behind, typically a `persistAtRest` drawer or sheet whose
open (at 1) and close (at 0) effects disagree. The check runs only in
development (`process.env.NODE_ENV !== "production"`, which bundlers replace)
and only in browsers, since it reads rendered styles.
### Haptics
`onArm`, `onDisarm`, and `onCommit` run inside the pointer event, which is
where platforms allow feedback:
- **Android, Chrome**: `navigator.vibrate(10)`.
- **iOS Safari**: there is no Vibration API. Haptics are only reachable
through a native wrapper, or through an undocumented technique: toggling a
hidden `<input type="checkbox" switch>`, which iOS 18+ Safari renders as a
switch with a system haptic. It only produces a haptic during a user gesture,
so call it from these handlers, never after an `await`, and expect it to
change between iOS releases.
- **Capacitor**: `Haptics.impact()` from `@capacitor/haptics`.
### Operating-system edge gestures
The screen edges belong to the operating system first. On iOS, a swipe from the
left edge goes back in Safari's history, including in apps installed to the
home screen (`display: standalone`), which have no back button. On Android
with gesture navigation, a swipe from either side edge is the system back
gesture. Pages cannot claim those touches, so `.from.left()` or `.from.right()`
with a narrow zone competes with them. Prefer a zone that starts a little
inside the edge, or a wider one (`.from.when(nearEdge("left", 56))`), and give
every back gesture an on-screen alternative.
Web apps cannot disable the operating system's gesture, but an installed
progressive web app can make it do the right thing, so it backs out of the
scene instead of leaving the app:
- **Android (Chrome 120+)**: listen for close requests with `CloseWatcher`.
The system back gesture and back button send one; route it to the scene's
back operation. A watcher must be created during a user activation (a tap
or a gesture), and after it fires it cannot fire again until the user
interacts again, so recreate it after each navigation:
```ts
let watcher: CloseWatcher | undefined;
function watchSystemBack(origin: UseOrigin) {
watcher?.destroy();
if (!("CloseWatcher" in window) || !origin.canGoBack.value) return;
watcher = new CloseWatcher();
watcher.onclose = () => origin.perform(back(slideRight));
}
// Call from a gesture's .onCommit() or a click handler, after navigating.
```
Without `CloseWatcher`, push a history entry when the scene navigates
forward (`history.pushState({ scene: true }, "")`) and handle `popstate` by
performing the scene's back. The system gesture then pops that entry instead
of leaving the app.
- **iOS (standalone)**: the edge swipe navigates browser history. Scenes never
add history entries, so in an installed app with no history to go back to
the swipe should have nothing to do. If the app also uses the History API,
map `popstate` to the scene's back as above.
- **Browser history swipes** (two-finger trackpad swipes, and horizontal
overscroll navigation in Chromium): `overscroll-behavior-x: none` on the page's
scroll container stops horizontal overscroll from triggering navigation.
Verify the behaviour on real devices: these platform gestures are not part of
any web standard and change between releases.
+4
View File
@@ -308,6 +308,10 @@ Each module depends only on the ones listed above it.
| `gesture/recognizer.ts` | Pointer-down admission, `DirectionalTracker`, `GestureRecognizer`, and `useOriginGesture` |
| `components/` | `OriginScene`, `OriginGesture`, `OriginGestureSurface`, and the internal node host |
Tests live in `tests/`, outside `src/`. `tests/property.test.ts` checks the
scene's structural invariants after every step of random operation sequences
and pointer streams; set `PROPERTY_SEEDS` to run more than the default 60.
Pure helpers used by a class (for example `matchesStart`, `stepSpring`, or
`composeNodeStyle`) are module-level `export const`s that the class calls, so
they can be tested directly without widening the class's interface.
+32 -9
View File
@@ -78,6 +78,12 @@ export interface OriginEffectSet {
export interface OriginChoreographyContext {
/** Normalized operation progress, clamped to the inclusive range `0..1`. */
progress: number;
/**
* How far a drag has gone past the ends of `progress`: negative when pulled
* back beyond the start, positive beyond full progress, otherwise `0`. Use
* it for rubber-banding or resistance; most choreographies ignore it.
*/
overshoot: number;
/**
* Normalized progress units per second. Positive velocity moves toward
* commit; negative velocity moves back toward cancellation.
@@ -111,15 +117,15 @@ export interface OriginChoreography {
/**
* Gesture progress required to retain the target after release.
*
* @defaultValue `0.36`
* @defaultValue `0.25`
*/
readonly commitThreshold?: number;
/**
* Reserved: normalized positive release velocity intended to commit a
* deliberate flick.
* Release velocity, in progress units per second, that commits a flick
* regardless of progress. Velocity is normalized by the gesture host's
* size, so `1` means one full host width (or height) per second.
*
* The scene currently commits any release whose velocity is at least `0.3`,
* regardless of this value. See `FLICK_COMMIT_VELOCITY` in the scene module.
* @defaultValue `0.2`
*/
readonly commitVelocity?: number;
/**
@@ -136,6 +142,27 @@ export interface OriginChoreography {
* @defaultValue `false`
*/
readonly persistAtRest?: boolean;
/**
* Settling spring after release. A drawer, a sheet, and a full-page push
* usually want different springs.
*/
readonly spring?: OriginSpring;
}
/** Damped-spring settings for settling a released operation. */
export interface OriginSpring {
/**
* Pull toward the resting position. Higher settles faster.
*
* @defaultValue `280`
*/
readonly stiffness?: number;
/**
* Resistance to motion. Lower overshoots and bounces.
*
* @defaultValue `30`
*/
readonly damping?: number;
}
/**
@@ -250,8 +277,6 @@ const percent = (value: number) => `${(value * 100).toFixed(4)}%`;
*/
export const slideLeft = defineOriginChoreography({
name: "slide-left",
commitThreshold: 0.36,
commitVelocity: 0.6,
effects: ({ progress }) => ({
source: {
transform: `translate3d(${percent(progress * -0.24)}, 0, 0) scale(${1 - progress * 0.025})`,
@@ -269,8 +294,6 @@ export const slideLeft = defineOriginChoreography({
*/
export const slideRight = defineOriginChoreography({
name: "slide-right",
commitThreshold: 0.36,
commitVelocity: 0.6,
effects: ({ progress }) => ({
source: {
transform: `translate3d(${percent(progress)}, 0, 0)`,
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useOriginGesture } from "../gesture/recognizer";
import type { OriginGestureProps } from "../gesture/definition";
import { useOriginGesture } from "../gesture/recognizer";
defineOptions({ name: "OriginGesture", inheritAttrs: false });
@@ -11,29 +11,24 @@ const props = withDefaults(defineProps<OriginGestureProps>(), {
/*
* This convenience component makes the declaration live exactly where the
* developer writes it. `useOriginGesture()` is also public for components that
* cannot accept an extra wrapper element. Props are read once, at setup; the
* legacy `action` prop is read lazily so it may change.
* cannot accept an extra wrapper element. A new `gesture` prop applies from
* the next pointer-down.
*/
const gesture = useOriginGesture(
props.gesture ?? {
direction: props.direction,
edge: props.edge,
threshold: props.threshold,
action: (context) => props.action?.(context),
},
);
const gesture = useOriginGesture(() => props.gesture);
</script>
<template>
<component
:is="as"
v-bind="$attrs"
:ref="gesture.ref"
v-bind="{ ...$attrs, ...gesture.listeners }"
class="nvo-gesture"
:style="gesture.style"
@pointerdown="gesture.onPointerdown"
@pointermove="gesture.onPointermove"
@pointerup="gesture.onPointerup"
@pointercancel="gesture.onPointercancel"
@lostpointercapture="gesture.onLostpointercapture"
>
<slot />
</component>
@@ -1,7 +1,6 @@
<script setup lang="ts">
import { computed } from "vue";
import type { OriginGestureSurfaceProps } from "../gesture/definition";
import { touchActionFor, useOriginGesture } from "../gesture/recognizer";
import { useOriginGestures } from "../gesture/recognizer";
defineOptions({ name: "OriginGestureSurface", inheritAttrs: false });
@@ -14,41 +13,21 @@ const props = withDefaults(defineProps<OriginGestureSurfaceProps>(), {
* This convenience host only installs them, combines their browser scrolling
* requirements, and forwards a pointer sequence to every recognizer.
*/
const bindings = props.gestures.map((definition) =>
useOriginGesture(definition),
);
const surfaceStyle = computed(
() =>
({
width: "100%",
height: "100%",
touchAction: touchActionFor(
props.gestures.map(({ direction }) => direction),
),
}) as const,
);
const onPointerdown = (event: PointerEvent) =>
bindings.forEach((binding) => binding.onPointerdown(event));
const onPointermove = (event: PointerEvent) =>
bindings.forEach((binding) => binding.onPointermove(event));
const onPointerup = (event: PointerEvent) =>
bindings.forEach((binding) => binding.onPointerup(event));
const onPointercancel = () =>
bindings.forEach((binding) => binding.onPointercancel());
const surface = useOriginGestures(() => props.gestures);
</script>
<template>
<component
:is="as"
v-bind="$attrs"
:ref="surface.ref"
v-bind="{ ...$attrs, ...surface.listeners }"
class="nvo-gesture"
:style="[$attrs.style, surfaceStyle]"
@pointerdown="onPointerdown"
@pointermove="onPointermove"
@pointerup="onPointerup"
@pointercancel="onPointercancel"
:style="[$attrs.style, surface.style]"
@pointerdown="surface.onPointerdown"
@pointermove="surface.onPointermove"
@pointerup="surface.onPointerup"
@pointercancel="surface.onPointercancel"
@lostpointercapture="surface.onLostpointercapture"
>
<slot />
</component>
+200 -96
View File
@@ -1,8 +1,9 @@
import type { CSSProperties } from "vue";
import type { CSSProperties, MaybeRefOrGetter } from "vue";
import type { OriginChoreography } from "../choreography";
import type { MaybeOriginAction, OriginNavigationIntent } from "../navigation";
import type { OriginNavigationIntent } from "../navigation";
import type { OriginContext } from "../scene/types";
import type { OriginRect } from "../shared";
import { isAtScrollEdge, withinEdge } from "./recognizer";
/*
* What a gesture is: the public gesture types, component props, and the
@@ -77,6 +78,15 @@ export type OriginGestureStart =
/** Recognize pointer-down when application policy returns `true`. */
readonly kind: "when";
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. */
@@ -93,8 +103,27 @@ export interface OriginGestureDirectionOptions {
* @defaultValue `1.15`
*/
readonly axisDominance?: number;
/**
* Movement that counts as full progress (`1`), resolved at pointer-down.
* A CSS length (numbers are pixels, `%` is of the host), or a function
* returning pixels, for example to measure a drawer narrower than the host.
*
* @defaultValue the host's width (horizontal) or height (vertical)
*/
readonly distance?: OriginGestureProgressDistance;
}
/**
* Movement that counts as full progress: a CSS length, or a function that
* returns pixels, measured at pointer-down.
*/
export type OriginGestureProgressDistance =
| OriginGestureDistance
| ((context: {
readonly host: HTMLElement;
readonly bounds: OriginRect;
}) => number);
/** Values available when a custom `.complete()` policy runs on pointer-up. */
export interface OriginGestureCompletionContext {
/** Component instance that originated the gesture. */
@@ -152,7 +181,7 @@ export type OriginGestureNavigationFactory = (
* represented as an `anywhere` start rule.
*/
export interface OriginGestureDefinition {
/** Discriminator used by the compatibility recognizer overload. */
/** Identifies a definition produced by the {@link gesture} builder. */
readonly kind: "origin-gesture-definition";
/** Pointer-down eligibility policy. */
readonly start: OriginGestureStart;
@@ -170,6 +199,10 @@ export interface OriginGestureDefinition {
readonly choreography: OriginChoreography;
/** Handlers run, in order, when a release commits. See `.onCommit()`. */
readonly commitHandlers?: readonly OriginGestureCommitHandler[];
/** Handlers run when a drag starts to qualify to commit. See `.onArm()`. */
readonly armHandlers?: readonly OriginGestureCommitHandler[];
/** Handlers run when a drag stops qualifying to commit. */
readonly disarmHandlers?: readonly OriginGestureCommitHandler[];
}
/**
@@ -216,8 +249,24 @@ export interface OriginGestureFromBuilder {
anywhere(): OriginGestureFromSelection;
/** Admit pointer-down when a synchronous application predicate succeeds. */
when(predicate: OriginGestureStartPredicate): OriginGestureFromSelection;
/**
* Admit pointer-down only while `scroller` is scrolled to `edge`, for
* example drag-down-to-dismiss only at the top of a list. Movement the
* other way is left to the scroller. See {@link atScrollEdge}.
*/
scrollEdge(
scroller: OriginGestureScroller,
edge: OriginGestureEdge,
): OriginGestureFromSelection;
}
/**
* A scroll container: an element, a ref or getter returning one, or a CSS
* selector resolved inside the gesture host.
*/
export type OriginGestureScroller =
MaybeRefOrGetter<Element | null | undefined> | string;
/** Builder stage after direction is known and completion remains optional. */
export interface OriginGestureDirectedBuilder {
/** Override the choreography's default release decision. */
@@ -230,6 +279,13 @@ export interface OriginGestureDirectedBuilder {
): OriginGestureNavigationBuilder;
/** Run `handler` when a release commits. Handlers accumulate in order. */
onCommit(handler: OriginGestureCommitHandler): OriginGestureDirectedBuilder;
/**
* Run `handler` during a drag when releasing would start to commit, for
* example for a "release now" haptic. See `.onArm()` in the API reference.
*/
onArm(handler: OriginGestureCommitHandler): OriginGestureDirectedBuilder;
/** Run `handler` during a drag when releasing would no longer commit. */
onDisarm(handler: OriginGestureCommitHandler): OriginGestureDirectedBuilder;
}
/** Builder stage after a custom completion policy has been selected. */
@@ -240,6 +296,13 @@ export interface OriginGestureCompletedBuilder {
): OriginGestureNavigationBuilder;
/** Run `handler` when a release commits. Handlers accumulate in order. */
onCommit(handler: OriginGestureCommitHandler): OriginGestureCompletedBuilder;
/**
* Run `handler` during a drag when releasing would start to commit, for
* example for a "release now" haptic. See `.onArm()` in the API reference.
*/
onArm(handler: OriginGestureCommitHandler): OriginGestureCompletedBuilder;
/** Run `handler` during a drag when releasing would no longer commit. */
onDisarm(handler: OriginGestureCommitHandler): OriginGestureCompletedBuilder;
}
/** Final builder stage waiting for visual choreography. */
@@ -248,6 +311,13 @@ export interface OriginGestureNavigationBuilder {
animate(choreography: OriginChoreography): OriginGestureDefinition;
/** Run `handler` when a release commits. Handlers accumulate in order. */
onCommit(handler: OriginGestureCommitHandler): OriginGestureNavigationBuilder;
/**
* Run `handler` during a drag when releasing would start to commit, for
* example for a "release now" haptic. See `.onArm()` in the API reference.
*/
onArm(handler: OriginGestureCommitHandler): OriginGestureNavigationBuilder;
/** Run `handler` during a drag when releasing would no longer commit. */
onDisarm(handler: OriginGestureCommitHandler): OriginGestureNavigationBuilder;
}
/** Root of the immutable fluent gesture-definition API. */
@@ -260,47 +330,19 @@ export interface OriginGestureBuilder {
readonly to: OriginGestureToBuilder;
}
/** Configuration consumed by {@link useOriginGesture}. */
export interface OriginGestureOptions {
/**
* Direction in which the pointer moves to advance the operation.
*
* The starting edge is the opposite side: `right` begins at the left edge,
* `left` at the right edge, `down` at the top, and `up` at the bottom.
*/
direction: OriginGestureDirection;
/**
* Restrict pointer-down to this many CSS pixels from the gesture host's
* starting edge. Omit it to recognize across the entire host.
*
* This is relative to the bound element, not necessarily the browser
* viewport. Use a positive number such as `24` or `36`.
*/
edge?: number;
/**
* Minimum directed movement in CSS pixels before the gesture captures.
*
* Movement must also dominate the cross-axis by a factor of `1.15`.
*
* @defaultValue `8`
*/
threshold?: number;
/**
* Resolve the scene action after directional recognition succeeds.
*
* Returning no action abandons recognition without modifying the scene.
*/
action(context: OriginContext): MaybeOriginAction;
}
/**
* DOM bindings returned by {@link useOriginGesture}.
*
* Spread or attach all four handlers to the same `HTMLElement`. Apply
* {@link style} as well so native scrolling is preserved on the cross-axis.
* Attach the pointer handlers to the same `HTMLElement` (and `onTouchmove`
* when {@link needsTouchmove} is true), and apply
* {@link style}: its `touch-action` keeps native scrolling on every axis no
* gesture claims.
*/
export interface OriginGestureBinding {
/** Required size and `touch-action` styles for the gesture host. */
/**
* Required size and `touch-action` styles for the gesture host. Reading it
* inside a render function tracks changes to the gesture definitions.
*/
readonly style: Readonly<CSSProperties>;
/** Pointer-down handler that records a potentially eligible gesture. */
readonly onPointerdown: (event: PointerEvent) => void;
@@ -310,64 +352,54 @@ export interface OriginGestureBinding {
readonly onPointerup: (event: PointerEvent) => void;
/** Pointer-cancel handler that abandons any captured operation. */
readonly onPointercancel: () => void;
/**
* Lost-capture handler. Cancels a captured gesture whose pointer capture
* ends without a pointer-up, so the operation never stays stuck mid-way.
*/
readonly onLostpointercapture: (event: PointerEvent) => void;
/**
* Touch-move handler for gestures that share an axis with native scrolling
* (`.from.scrollEdge()`): it cancels scrolling for a touch such a gesture
* 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;
/**
* 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 ----------------------------------------------------------
/** Props shared by both `OriginGesture` declaration styles. */
export interface OriginGestureBaseProps {
/** Public props accepted by the `OriginGesture` convenience component. */
export interface OriginGestureProps {
/**
* Native HTML tag rendered as the gesture host.
*
* @defaultValue `"div"`
*/
as?: string;
}
/** Builder-definition props accepted by the `OriginGesture` component. */
export interface OriginGestureDefinitionProps extends OriginGestureBaseProps {
/** Immutable definition produced by the {@link gesture} builder. */
/**
* Immutable definition produced by the {@link gesture} builder. Replacing
* it takes effect from the next pointer-down; a gesture in progress keeps
* the definition it started with.
*/
gesture: OriginGestureDefinition;
/** Builder definitions already contain direction. */
direction?: never;
/** Builder definitions already contain their start policy. */
edge?: never;
/** Builder definitions already contain recognition tuning. */
threshold?: never;
/** Builder definitions already contain their navigation factory. */
action?: never;
}
/** Legacy option props accepted by the `OriginGesture` component. */
export interface OriginGestureLegacyProps extends OriginGestureBaseProps {
/** Legacy component declarations do not provide a builder definition. */
gesture?: never;
/** Direction in which pointer movement advances operation progress. */
direction: OriginGestureDirection;
/**
* Eligible pointer-down width in CSS pixels from the host's starting edge.
* Omit it to allow the full component surface.
*/
edge?: number;
/**
* Directed movement required before capture.
*
* @defaultValue `8`
*/
threshold?: number;
/** Node-local action factory invoked only after recognition succeeds. */
action(context: OriginContext): MaybeOriginAction;
}
/**
* Public props accepted by the `OriginGesture` convenience component.
*
* Prefer the builder-definition form. The legacy direction/action form remains
* available for compatibility.
*/
export type OriginGestureProps =
OriginGestureDefinitionProps | OriginGestureLegacyProps;
/** Public props accepted by the multi-definition gesture surface component. */
export interface OriginGestureSurfaceProps {
/**
@@ -380,7 +412,9 @@ export interface OriginGestureSurfaceProps {
* Complete immutable definitions installed on the shared host.
*
* The surface adds no start, direction, completion, navigation, or animation
* policy. Definitions should be created by the owning page component.
* policy. Definitions should be created by the owning page component. The
* list may change; a gesture in progress keeps the definition it started
* with.
*/
gestures: readonly OriginGestureDefinition[];
}
@@ -403,16 +437,33 @@ interface GestureDraft {
readonly recognition: Readonly<OriginGestureDirectionOptions>;
readonly completion?: OriginGestureCompletionPredicate;
readonly commitHandlers: readonly OriginGestureCommitHandler[];
readonly armHandlers: readonly OriginGestureCommitHandler[];
readonly disarmHandlers: readonly OriginGestureCommitHandler[];
}
const withCommitHandler = <Draft extends GestureDraft>(
type HandlerList = "commitHandlers" | "armHandlers" | "disarmHandlers";
/**
* `.onCommit()`, `.onArm()`, and `.onDisarm()` for a stage: each rebuilds the
* same stage from the draft with the handler appended.
*/
function hookMethods<Draft extends GestureDraft, Stage>(
draft: Draft,
handler: OriginGestureCommitHandler,
): Draft =>
stage: (draft: Draft) => Stage,
) {
const append = (list: HandlerList) => (handler: OriginGestureCommitHandler) =>
stage(
Object.freeze({
...draft,
commitHandlers: Object.freeze([...draft.commitHandlers, handler]),
});
[list]: Object.freeze([...draft[list], handler]),
}),
);
return {
onCommit: append("commitHandlers"),
onArm: append("armHandlers"),
onDisarm: append("disarmHandlers"),
};
}
function navigationStage(
draft: GestureDraft & { readonly navigation: OriginGestureNavigationFactory },
@@ -428,9 +479,10 @@ function navigationStage(
navigation: draft.navigation,
choreography,
commitHandlers: draft.commitHandlers,
armHandlers: draft.armHandlers,
disarmHandlers: draft.disarmHandlers,
}),
onCommit: (handler: OriginGestureCommitHandler) =>
navigationStage(withCommitHandler(draft, handler)),
...hookMethods(draft, navigationStage),
});
}
@@ -441,8 +493,7 @@ const navigateFrom =
function completedStage(draft: GestureDraft): OriginGestureCompletedBuilder {
return Object.freeze({
navigate: navigateFrom(draft),
onCommit: (handler: OriginGestureCommitHandler) =>
completedStage(withCommitHandler(draft, handler)),
...hookMethods(draft, completedStage),
});
}
@@ -451,8 +502,7 @@ function directedStage(draft: GestureDraft): OriginGestureDirectedBuilder {
complete: (completion: OriginGestureCompletionPredicate) =>
completedStage(Object.freeze({ ...draft, completion })),
navigate: navigateFrom(draft),
onCommit: (handler: OriginGestureCommitHandler) =>
directedStage(withCommitHandler(draft, handler)),
...hookMethods(draft, directedStage),
});
}
@@ -466,6 +516,8 @@ function toStage(start: OriginGestureStart): OriginGestureToBuilder {
direction,
recognition: Object.freeze({ ...options }),
commitHandlers: Object.freeze([]),
armHandlers: Object.freeze([]),
disarmHandlers: Object.freeze([]),
}),
);
return Object.freeze({
@@ -492,8 +544,60 @@ const fromBuilder: OriginGestureFromBuilder = Object.freeze({
anywhere: () => fromStage({ kind: "anywhere" }),
when: (predicate: OriginGestureStartPredicate) =>
fromStage({ kind: "when", predicate }),
scrollEdge: (scroller: OriginGestureScroller, edge: OriginGestureEdge) =>
fromStage({ kind: "scroll-edge", scroller, edge }),
});
// Start rules ---------------------------------------------------------------
/*
* Composable `.from.when()` predicates. Each returns a start predicate, so
* they combine with anyOf() and allOf():
*
* gesture.from
* .when(anyOf(startedIn(".sheet-handle"), atScrollEdge(list, "top")))
* .to.down()
*/
/** Admit pointer-down on an element matching `selector`, or inside one. */
export const startedIn =
(selector: string): OriginGestureStartPredicate =>
({ event }) =>
event.target instanceof Element && event.target.closest(selector) !== null;
/** Admit pointer-down within `distance` of one side of the gesture host. */
export const nearEdge =
(
edge: OriginGestureEdge,
distance: OriginGestureDistance,
): OriginGestureStartPredicate =>
({ host, bounds, point }) =>
withinEdge(edge, distance, host, bounds, point);
/**
* Admit pointer-down while `scroller` is scrolled all the way to `edge`.
* A scroller that is missing (not yet mounted) counts as at the edge.
*/
export const atScrollEdge =
(
scroller: OriginGestureScroller,
edge: OriginGestureEdge,
): OriginGestureStartPredicate =>
({ host }) =>
isAtScrollEdge(scroller, edge, host);
/** Admit pointer-down when any of `predicates` does. */
export const anyOf =
(...predicates: OriginGestureStartPredicate[]): OriginGestureStartPredicate =>
(context) =>
predicates.some((predicate) => predicate(context));
/** Admit pointer-down only when all of `predicates` do. */
export const allOf =
(...predicates: OriginGestureStartPredicate[]): OriginGestureStartPredicate =>
(context) =>
predicates.every((predicate) => predicate(context));
/**
* Root of the immutable gesture builder.
*
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -28,6 +28,7 @@ export type {
OriginEffectSet,
OriginOperationIntent,
OriginOperationPhase,
OriginSpring,
} from "./choreography";
export {
defineOriginChoreography,
@@ -50,6 +51,7 @@ export {
export type * from "./scene/types";
export type { OriginNodeScope, UseOrigin } from "./scene/scene";
export { OriginSceneError, type OriginSceneErrorCode } from "./scene/graph";
export {
createOriginScene,
originNodeScopeKey,
@@ -57,7 +59,14 @@ export {
} from "./scene/scene";
export type * from "./gesture/definition";
export { gesture } from "./gesture/definition";
export {
allOf,
anyOf,
atScrollEdge,
gesture,
nearEdge,
startedIn,
} from "./gesture/definition";
export { useOriginGesture } from "./gesture/recognizer";
/**
-10
View File
@@ -147,16 +147,6 @@ export interface OriginAction {
readonly history?: OriginHistoryMode;
}
/**
* Values accepted from gesture action factories.
*
* Returning `null` or `undefined` declines the recognized gesture. A promise
* allows lazy target resolution; stale results are discarded after release or
* cancellation.
*/
export type MaybeOriginAction =
OriginAction | null | undefined | Promise<OriginAction | null | undefined>;
/** Options accepted by {@link originAction}. */
export interface OriginActionOptions {
/**
+188 -47
View File
@@ -24,30 +24,71 @@ export interface SceneNodeRecord {
incomingOperationId?: number;
}
/**
* Why a scene operation was rejected.
*
* - `missing-node`: the node no longer exists.
* - `missing-origin`: the origin node does not exist.
* - `inert-origin`: the origin is parked or exposed and cannot originate.
* - `origin-busy`: the origin already has a live outgoing operation.
* - `invalid-action`: the action cannot be executed as written.
* - `no-previous-entry`: back was requested without a retained entry.
* - `undecided-forward`: back was requested through an unreleased forward.
* - `target-busy`: the retained back target is transitioning, or still
* positions the origin.
* - `outside-scene`: `useOrigin()` was called outside an `<OriginScene>` view.
*/
export type OriginSceneErrorCode =
| "missing-node"
| "missing-origin"
| "inert-origin"
| "origin-busy"
| "invalid-action"
| "no-previous-entry"
| "undecided-forward"
| "target-busy"
| "outside-scene";
/** Error thrown or rejected by scene operations, with a stable `code`. */
export class OriginSceneError extends Error {
override readonly name = "OriginSceneError";
constructor(
readonly code: OriginSceneErrorCode,
message: string,
) {
super(message);
}
}
/**
* Progress at which a release commits when the choreography does not supply
* its own `commitThreshold`.
*/
export const DEFAULT_COMMIT_THRESHOLD = 0.36;
export const DEFAULT_COMMIT_THRESHOLD = 0.25;
/**
* Release velocity (progress units per second) that commits a flick at any
* progress. Choreographies' `commitVelocity` is not currently consulted.
* progress when the choreography does not supply its own `commitVelocity`.
*/
export const FLICK_COMMIT_VELOCITY = 0.3;
export const DEFAULT_COMMIT_VELOCITY = 0.2;
/** Decide a release outcome from explicit options or progress/velocity. */
/**
* Decide a release outcome from explicit options, or from velocity and
* progress: a flick at least `commitVelocity` fast decides by its direction,
* forward committing and backward cancelling, regardless of distance. Slower
* releases commit once progress reaches `commitThreshold`.
*/
export const shouldCommit = (
operation: MutableOriginOperation,
options: OriginFinishOptions,
): boolean => {
if (options.commit !== undefined) return options.commit;
const threshold =
operation.choreography.commitThreshold ?? DEFAULT_COMMIT_THRESHOLD;
return (
operation.progress >= threshold ||
(operation.progress >= 0 && operation.velocity >= FLICK_COMMIT_VELOCITY)
);
const { commitThreshold, commitVelocity } = operation.choreography;
const flick = commitVelocity ?? DEFAULT_COMMIT_VELOCITY;
if (operation.velocity >= flick) return true;
if (operation.velocity <= -flick) return false;
return operation.progress >= (commitThreshold ?? DEFAULT_COMMIT_THRESHOLD);
};
/**
@@ -76,6 +117,14 @@ export class SceneGraph {
* that back operation's id. Cancelling the back operation restores the edge.
*/
private readonly suspendedEdges = new Map<number, MutableOriginOperation>();
/**
* Operations indexed by origin key, in insertion order. Every addition and
* removal goes through {@link attach} and {@link detach} to keep it in sync
* with `operations`.
*/
private readonly outgoing = shallowReactive(
new Map<string, readonly MutableOriginOperation[]>(),
);
private nodeSequence = 0;
private operationSequence = 0;
@@ -117,10 +166,9 @@ export class SceneGraph {
/** The unfinished operation originated by a node, if any. */
liveOutgoingOf(nodeKey: string): MutableOriginOperation | undefined {
for (const operation of this.operations.values())
if (operation.originKey === nodeKey && operation.phase !== "finished")
return operation;
return undefined;
return this.outgoing
.get(nodeKey)
?.find((operation) => operation.phase !== "finished");
}
/**
@@ -128,9 +176,7 @@ export class SceneGraph {
* {@link liveOutgoingOf}, this includes a finished, connected resting edge.
*/
presentedOutgoingOf(nodeKey: string): MutableOriginOperation | undefined {
for (const operation of this.operations.values())
if (operation.originKey === nodeKey) return operation;
return undefined;
return this.outgoing.get(nodeKey)?.[0];
}
/** Retained entries preceding `node`, oldest first. */
@@ -151,7 +197,10 @@ export class SceneGraph {
contextFor(nodeKey: string): OriginContext {
const node = this.nodes.get(nodeKey);
if (!node)
throw new Error(`Origin scene node "${nodeKey}" no longer exists.`);
throw new OriginSceneError(
"missing-node",
`Origin scene node "${nodeKey}" no longer exists.`,
);
const history = this.historyOf(node).map((entry) => entry.view);
return {
nodeKey,
@@ -194,7 +243,8 @@ export class SceneGraph {
const origin = this.assertCanOriginate(originKey);
const history = action.history ?? "push";
if (action.choreography.persistAtRest && history !== "push")
throw new Error(
throw new OriginSceneError(
"invalid-action",
"persistAtRest choreographies require retained-history push navigation.",
);
@@ -224,6 +274,7 @@ export class SceneGraph {
const operation = this.operations.get(id);
if (!operation || operation.phase === "finished") return;
operation.progress = clamp01(progress);
operation.overshoot = progress - operation.progress;
operation.velocity = velocity;
if (operation.phase !== "preparing") operation.phase = "interactive";
}
@@ -247,6 +298,7 @@ export class SceneGraph {
if (operation.history === "push" && operation.choreography.persistAtRest) {
operation.progress = 1;
operation.overshoot = 0;
operation.velocity = 0;
operation.intent = "commit";
operation.phase = "finished";
@@ -257,7 +309,7 @@ export class SceneGraph {
this.transferPosition(origin, target);
operation.phase = "finished";
this.operations.delete(operation.id);
this.detach(operation);
this.suspendedEdges.delete(operation.id);
if (operation.history === "push") {
@@ -265,7 +317,11 @@ export class SceneGraph {
origin.state = "parked";
} else {
// Back pops the current entry; replace discards it in favor of the new
// target. Both preserve every earlier retained instance.
// target. Both preserve every earlier retained instance, and any entry
// still retained above the origin now sits on the target instead.
for (const node of this.nodes.values())
if (node.previousNodeKey === origin.key && node !== target)
node.previousNodeKey = target.key;
this.removeNode(origin.key);
}
@@ -276,33 +332,37 @@ export class SceneGraph {
/**
* Finalize a cancelled operation, restoring the origin.
*
* A cancelled back re-parks its retained target, or re-attaches the resting
* edge it suspended. A cancelled push/replace removes its new target branch.
* Everything the edge created or now positions is undone. Entries from the
* origin's retained history (a back target, or a replacement of one) return
* to history; anything newer is discarded. A cancelled back that suspended
* a connected resting edge reconnects it instead.
*/
cancel(id: number): void {
const operation = this.operations.get(id);
if (!operation) return;
const origin = this.nodes.get(operation.originKey);
const target = this.nodes.get(operation.entryTargetKey);
const entry = this.nodes.get(operation.entryTargetKey);
const current = this.nodes.get(operation.targetKey);
const suspendedEdge = this.suspendedEdges.get(id);
const retained = origin ? this.retainedKeys(origin) : new Set<string>();
operation.phase = "finished";
this.operations.delete(id);
this.detach(operation);
this.suspendedEdges.delete(id);
if (operation.history === "back") {
if (suspendedEdge && origin && target) {
if (suspendedEdge && origin && entry && current === entry) {
// Put the retained page back into its former position, then reconnect
// the resting page→drawer edge. Both nodes keep their VNodes.
this.transferPosition(origin, target);
this.operations.set(suspendedEdge.id, suspendedEdge);
this.discardOutgoing(entry.key, new Set(retained));
this.transferPosition(origin, entry);
this.attach(suspendedEdge);
origin.incomingOperationId = suspendedEdge.id;
target.state = "exposed";
} else if (target) {
target.incomingOperationId = undefined;
target.state = "parked";
}
entry.state = "exposed";
} else {
this.removeBranch(operation.entryTargetKey);
for (const node of new Set([entry, current])) {
if (!node || node === origin) continue;
if (retained.has(node.key)) this.returnToHistory(node, retained);
else this.removeBranch(node.key, new Set(retained));
}
}
if (origin) this.activate(origin);
@@ -313,13 +373,18 @@ export class SceneGraph {
private assertCanOriginate(originKey: string): SceneNodeRecord {
const origin = this.nodes.get(originKey);
if (!origin)
throw new Error(`Cannot animate from missing origin "${originKey}".`);
throw new OriginSceneError(
"missing-origin",
`Cannot animate from missing origin "${originKey}".`,
);
if (origin.state === "parked" || origin.state === "exposed")
throw new Error(
throw new OriginSceneError(
"inert-origin",
`Cannot animate from ${origin.state} origin "${originKey}".`,
);
if (this.liveOutgoingOf(originKey))
throw new Error(
throw new OriginSceneError(
"origin-busy",
`Origin "${originKey}" already has an outgoing operation. ` +
"A descendant target may start its own operation instead.",
);
@@ -341,12 +406,14 @@ export class SceneGraph {
} {
const previousKey = origin.previousNodeKey;
if (!previousKey)
throw new Error(
throw new OriginSceneError(
"no-previous-entry",
`Cannot go back from origin "${origin.key}" without a previous entry.`,
);
const target = this.nodes.get(previousKey);
if (!target)
throw new Error(
throw new OriginSceneError(
"no-previous-entry",
`Cannot go back to missing retained entry "${previousKey}".`,
);
@@ -354,7 +421,8 @@ export class SceneGraph {
const incoming = this.incomingOf(origin);
if (incoming?.originKey === target.key) {
if (incoming.intent !== "commit")
throw new Error(
throw new OriginSceneError(
"undecided-forward",
"Cannot go back through an undecided forward operation.",
);
if (incoming.phase !== "finished") this.commit(incoming.id);
@@ -362,19 +430,32 @@ export class SceneGraph {
const resting = this.operations.get(incoming.id);
if (resting?.phase === "finished" && resting.choreography.persistAtRest) {
// Re-root A→page→drawer as A→drawer before adding drawer→page.
this.operations.delete(resting.id);
this.detach(resting);
this.transferPosition(target, origin);
target.incomingOperationId = undefined;
suspendedEdge = resting;
}
}
// A target still positioning the origin (through edges other than the
// one collapsed above) would close a visual cycle.
if (
this.incomingChain(origin).some(
(operation) => operation.originKey === target.key,
)
)
throw new OriginSceneError(
"target-busy",
`Retained target "${target.key}" still positions origin "${origin.key}".`,
);
if (this.liveOutgoingOf(target.key))
throw new Error(
throw new OriginSceneError(
"target-busy",
`Retained target "${target.key}" already has an outgoing operation.`,
);
if (target.incomingOperationId)
throw new Error(
throw new OriginSceneError(
"target-busy",
`Retained target "${target.key}" is already transitioning.`,
);
return { target, suspendedEdge };
@@ -387,7 +468,8 @@ export class SceneGraph {
history: Exclude<OriginHistoryMode, "back">,
): SceneNodeRecord {
if (!action.target)
throw new Error(
throw new OriginSceneError(
"invalid-action",
`A "${history}" operation requires a target view recipe.`,
);
// A replacement skips the origin in the retained chain. The origin stays
@@ -397,7 +479,16 @@ export class SceneGraph {
return this.createNode(action.target, previousKey);
}
/**
* Make a node presentable again. A node still holding a connected resting
* edge (an open drawer, say) stays exposed rather than becoming active.
*/
private activate(node: SceneNodeRecord): void {
const resting = this.presentedOutgoingOf(node.key);
if (resting?.phase === "finished") {
node.state = "exposed";
return;
}
node.state = "active";
this.refreshPresentedState(node);
}
@@ -456,23 +547,72 @@ export class SceneGraph {
placement: init.placement,
history: init.history,
progress: 0,
overshoot: 0,
velocity: 0,
phase: "preparing",
intent: "undecided",
originRect: init.originRect,
});
this.operations.set(operation.id, operation);
this.attach(operation);
return operation;
}
private attach(operation: MutableOriginOperation): void {
this.operations.set(operation.id, operation);
const siblings = this.outgoing.get(operation.originKey) ?? [];
this.outgoing.set(operation.originKey, [...siblings, operation]);
}
private detach(operation: MutableOriginOperation): void {
this.operations.delete(operation.id);
const siblings = (this.outgoing.get(operation.originKey) ?? []).filter(
(sibling) => sibling.id !== operation.id,
);
if (siblings.length) this.outgoing.set(operation.originKey, siblings);
else this.outgoing.delete(operation.originKey);
}
private removeNode(nodeKey: string): void {
this.nodes.delete(nodeKey);
this.roots.value = this.roots.value.filter((key) => key !== nodeKey);
}
/**
* Resolve the operations a node started, because the node is leaving
* the visible graph: nodes they created (push/replace targets, including
* replacements whose history skips this node) are removed, and retained
* entries they revealed (back targets) are restored as if that back
* operation were cancelled.
*/
/** Park a retained entry again, discarding what it started meanwhile. */
private returnToHistory(node: SceneNodeRecord, retained: Set<string>): void {
this.discardOutgoing(node.key, new Set(retained));
node.incomingOperationId = undefined;
node.state = "parked";
}
/** A node's key plus its retained history, which a cancel must keep. */
private retainedKeys(node: SceneNodeRecord): Set<string> {
return new Set([node.key, ...this.historyOf(node).map(({ key }) => key)]);
}
private discardOutgoing(nodeKey: string, visited = new Set<string>()): void {
for (const operation of [...(this.outgoing.get(nodeKey) ?? [])]) {
// Includes finished resting edges: an open drawer closes with its page.
if (operation.history === "back") {
this.cancel(operation.id);
} else {
this.detach(operation);
this.removeBranch(operation.entryTargetKey, visited);
this.removeBranch(operation.targetKey, visited);
}
}
}
/**
* Remove a node, every node whose retained history passes through it, and
* every operation touching any of them. Earlier entries are untouched.
* Operations the node itself started are discarded first.
*/
private removeBranch(nodeKey: string, visited = new Set<string>()): void {
if (visited.has(nodeKey)) return;
@@ -481,13 +621,14 @@ export class SceneGraph {
for (const node of [...this.nodes.values()])
if (node.previousNodeKey === nodeKey)
this.removeBranch(node.key, visited);
this.discardOutgoing(nodeKey, visited);
for (const operation of [...this.operations.values()])
if (
operation.originKey === nodeKey ||
operation.targetKey === nodeKey ||
operation.entryTargetKey === nodeKey
)
this.operations.delete(operation.id);
this.detach(operation);
this.removeNode(nodeKey);
}
+276 -51
View File
@@ -3,6 +3,7 @@ import {
inject,
markRaw,
nextTick,
shallowRef,
type ComputedRef,
type CSSProperties,
type InjectionKey,
@@ -12,10 +13,14 @@ import {
normalizedEffect,
type OriginEffect,
type OriginEffectSet,
type OriginSpring,
type OriginOperationIntent,
type OriginOperationPhase,
} from "../choreography";
import type { OriginAction, OriginView } from "../navigation";
import {
DEV,
warn,
clamp,
clamp01,
measureRect,
@@ -23,7 +28,12 @@ import {
windowRect,
type OriginRect,
} from "../shared";
import { SceneGraph, shouldCommit, type SceneNodeRecord } from "./graph";
import {
OriginSceneError,
SceneGraph,
shouldCommit,
type SceneNodeRecord,
} from "./graph";
import type {
CreateOriginSceneOptions,
MutableOriginOperation,
@@ -66,24 +76,13 @@ const Z_INDEX_PER_LAYER = 10_000;
* Ancestors' source effects are intentionally excluded: only the initiating
* component receives the source side of an operation.
*
* @param viewport - Measures the scene bounds supplied to choreographies.
* @param effectsOf - The current effect set of an operation's choreography.
*/
export const nodeEffectLayers = (
graph: SceneGraph,
node: SceneNodeRecord,
viewport: () => OriginRect,
effectsOf: (operation: MutableOriginOperation) => OriginEffectSet,
): OriginEffect[] => {
const effectsOf = (operation: MutableOriginOperation): OriginEffectSet =>
operation.choreography.effects({
progress: operation.progress,
velocity: operation.velocity,
phase: operation.phase,
intent: operation.intent,
originRect: operation.originRect,
targetRect: operation.targetRect,
viewport: viewport(),
});
const chain = graph.incomingChain(node);
// Evaluate choreographies node-first, then apply them outermost-first.
const effectSets = chain.map(effectsOf);
@@ -111,16 +110,13 @@ export const nodeEffectLayers = (
export const composeNodeStyle = (
graph: SceneGraph,
nodeKey: string,
viewport: () => OriginRect,
effectsOf: (operation: MutableOriginOperation) => OriginEffectSet,
): CSSProperties => {
const node = graph.node(nodeKey);
if (!node) return {};
// Measure the scene at most once, and only when an edge needs it.
let measured: OriginRect | undefined;
const lazyViewport = () => (measured ??= viewport());
const { style, layer } = composeEffects(
nodeEffectLayers(graph, node, lazyViewport),
nodeEffectLayers(graph, node, effectsOf),
NODE_BASE_STYLE,
);
style.zIndex = Z_INDEX_BASE + layer * Z_INDEX_PER_LAYER + node.sequence;
@@ -143,8 +139,8 @@ export const composeNodeStyle = (
* easing on custom choreographies.
*/
const STIFFNESS = 280;
const DAMPING = 30;
const DEFAULT_STIFFNESS = 280;
const DEFAULT_DAMPING = 30;
/** Fixed integration step, in seconds. */
const STEP = 1 / 120;
/** Frame durations are clamped to this range, in seconds. */
@@ -170,13 +166,16 @@ export const stepSpring = (
state: SpringState,
target: number,
elapsedMs: number,
spring: OriginSpring = {},
): SpringState & { readonly done: boolean } => {
const stiffness = spring.stiffness ?? DEFAULT_STIFFNESS;
const damping = spring.damping ?? DEFAULT_DAMPING;
const elapsed = clamp(elapsedMs / 1000, MIN_FRAME, MAX_FRAME);
const iterations = Math.max(1, Math.ceil(elapsed / STEP));
const dt = elapsed / iterations;
let { position, velocity } = state;
for (let index = 0; index < iterations; index += 1) {
const acceleration = (target - position) * STIFFNESS - velocity * DAMPING;
const acceleration = (target - position) * stiffness - velocity * damping;
velocity += acceleration * dt;
position += velocity * dt;
}
@@ -190,13 +189,14 @@ export const stepSpring = (
* Run a spring on `requestAnimationFrame` until it rests at `target` or
* `onFrame` returns `false` because its subject no longer exists.
*
* Every reported progress is clamped to `0..1`; the final frame reports
* exactly `target` with zero velocity.
* Each frame reports the raw position; the final frame reports exactly
* `target` with zero velocity.
*/
export const runSpring = (
from: SpringState,
target: number,
onFrame: (progress: number, velocity: number) => boolean,
onFrame: (position: number, velocity: number) => boolean,
spring: OriginSpring = {},
): Promise<void> =>
new Promise<void>((resolve) => {
let state: SpringState = {
@@ -210,18 +210,66 @@ export const runSpring = (
let previous = performance.now();
const frame = (time: number) => {
const next = stepSpring(state, target, time - previous);
const next = stepSpring(state, target, time - previous, spring);
previous = time;
state = next;
const alive = next.done
? onFrame(target, 0)
: onFrame(clamp01(next.position), next.velocity);
: onFrame(next.position, next.velocity);
if (!alive || next.done) resolve();
else window.requestAnimationFrame(frame);
};
window.requestAnimationFrame(frame);
});
// Continuity check (development only) ---------------------------------------
/** A host's rendered transform as a 4×4 matrix, plus its opacity. */
interface Pose {
readonly matrix: readonly number[];
readonly opacity: number;
}
const warnedChoreographies = new WeakSet<object>();
/** Read a visible host's rendered pose; `null` if hidden or unreadable. */
const poseOf = (host: HTMLElement): Pose | null => {
const style = getComputedStyle(host);
if (style.visibility === "hidden") return null;
const matrix = parseMatrix(style.transform);
const opacity = Number(style.opacity);
return matrix && Number.isFinite(opacity) ? { matrix, opacity } : null;
};
/** Parse a computed `transform` ("none", matrix() or matrix3d()). */
const parseMatrix = (transform: string): number[] | null => {
if (!transform || transform === "none")
return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
const values = /^matrix(3d)?\(([^)]*)\)$/.exec(transform);
if (!values) return null;
const numbers = values[2]!.split(",").map(Number);
if (numbers.some((value) => !Number.isFinite(value))) return null;
if (values[1]) return numbers.length === 16 ? numbers : null;
if (numbers.length !== 6) return null;
const [a, b, c, d, e, f] = numbers as [
number,
number,
number,
number,
number,
number,
];
return [a, b, 0, 0, c, d, 0, 0, 0, 0, 1, 0, e, f, 0, 1];
};
/** Equal within half a pixel of translation and 1% of anything else. */
const posesMatch = (a: Pose, b: Pose): boolean =>
Math.abs(a.opacity - b.opacity) <= 0.01 &&
a.matrix.every((value, index) => {
const tolerance = index === 12 || index === 13 || index === 14 ? 0.5 : 0.01;
return Math.abs(value - b.matrix[index]!) <= tolerance;
});
// Scene ---------------------------------------------------------------------
/** Imperative controller for one operation, returned by `Scene.begin()`. */
@@ -278,6 +326,31 @@ class Scene implements OriginScene {
/** DOM registered by the renderer, used only for measurement. */
private readonly hosts = new Map<string, HTMLElement>();
private container: HTMLElement | null = null;
private resizeObserver: ResizeObserver | null = null;
/**
* Scene-container bounds, or the browser window before mounting. Cached and
* refreshed on resize and whenever an operation begins, so compositing
* never forces layout.
*/
private readonly viewport = shallowRef<OriginRect>(windowRect());
/**
* One memoized effect set per operation. It recomputes only when that
* operation's progress, phase, intent, rects, or the viewport change, so
* nodes sharing an edge do not evaluate its choreography again.
*/
/** The latest release (finish call) of each operation. */
private readonly releases = new WeakMap<MutableOriginOperation, object>();
/** Final outcome of each operation, once a release has decided it. */
private readonly outcomes = new WeakMap<MutableOriginOperation, boolean>();
/** Interrupted releases waiting for a later release to decide. */
private readonly waiters = new WeakMap<
MutableOriginOperation,
Array<(committed: boolean) => void>
>();
private readonly effectSets = new WeakMap<
MutableOriginOperation,
ComputedRef<OriginEffectSet>
>();
constructor(options: CreateOriginSceneOptions) {
// Never proxy the scene: its methods rely on raw internal state.
@@ -307,6 +380,10 @@ class Scene implements OriginScene {
originKey: string,
action: OriginAction,
): Promise<OriginOperationHandle> {
if (this.container) this.refreshViewport();
// Only a scene at rest has a pose the new operation must continue from.
const poses =
DEV && this.operations.value.length === 0 ? this.capturePoses() : null;
const operation = this.graph.begin(
originKey,
action,
@@ -319,6 +396,7 @@ class Scene implements OriginScene {
await nextTick();
operation.targetRect = this.measure(targetKey);
operation.phase = "interactive";
if (poses) this.warnOnJump(poses, operation);
return new OperationHandle(this, operation, targetKey);
}
@@ -330,25 +408,77 @@ class Scene implements OriginScene {
/**
* Fix an operation's outcome, settle it, and rewrite the graph.
*
* Calling `update()` while an operation settles "catches" it: the spring
* stops and progress is under control again. A later `finish()` then
* decides the outcome; every earlier, interrupted `finish()` resolves with
* that final outcome instead of applying its own.
*
* @internal Called through {@link OperationHandle}.
*/
async finish(id: number, options: OriginFinishOptions = {}) {
finish(id: number, options: OriginFinishOptions = {}): Promise<boolean> {
const operation = this.graph.operation(id);
if (!operation) return false;
if (operation.phase === "finished") return operation.intent === "commit";
if (!operation) return Promise.resolve(false);
if (operation.phase === "finished")
return Promise.resolve(operation.intent === "commit");
const release = {};
this.releases.set(operation, release);
return this.release(operation, release, options);
}
private async release(
operation: MutableOriginOperation,
release: object,
options: OriginFinishOptions,
): Promise<boolean> {
// The outcome is fixed at release, so the target can originate another
// operation while this one is only visually settling.
const commit = shouldCommit(operation, options);
operation.intent = commit ? "commit" : "cancel";
operation.phase = "settling";
await this.settle(id, commit ? 1 : 0, options.animate ?? true);
const current = () =>
this.releases.get(operation) === release &&
operation.phase === "settling";
await this.settle(
operation,
commit ? 1 : 0,
options.animate ?? true,
current,
);
// Another release already decided this operation.
const decided = this.outcomes.get(operation);
if (decided !== undefined) return decided;
// A later operation may already have finalized this edge.
if (!this.graph.operation(id)) return commit;
if (commit) return this.graph.commit(id);
this.graph.cancel(id);
return false;
const finished = (operation.phase as OriginOperationPhase) === "finished";
if (!this.graph.operation(operation.id) || finished)
return this.decide(
operation,
finished ? operation.intent === "commit" : commit,
);
// Caught by update() or superseded by a later finish(): wait for the
// release that decides.
if (!current())
return new Promise<boolean>((resolve) =>
this.waiters.set(operation, [
...(this.waiters.get(operation) ?? []),
resolve,
]),
);
return this.decide(
operation,
commit
? this.graph.commit(operation.id)
: (this.graph.cancel(operation.id), false),
);
}
/** Record an operation's final outcome and settle interrupted releases. */
private decide(operation: MutableOriginOperation, committed: boolean) {
this.outcomes.set(operation, committed);
for (const resolve of this.waiters.get(operation) ?? []) resolve(committed);
this.waiters.delete(operation);
return committed;
}
registerElement(nodeKey: string, element: HTMLElement | null): void {
@@ -357,34 +487,96 @@ class Scene implements OriginScene {
}
registerContainer(element: HTMLElement | null): void {
if (element === this.container) return;
this.resizeObserver?.disconnect();
this.resizeObserver = null;
this.container = element;
if (!element) return;
this.refreshViewport();
if (typeof ResizeObserver !== "undefined") {
this.resizeObserver = new ResizeObserver(() => this.refreshViewport());
this.resizeObserver.observe(element);
}
}
styleForNode(nodeKey: string): CSSProperties {
return composeNodeStyle(this.graph, nodeKey, () => this.viewport());
return composeNodeStyle(this.graph, nodeKey, this.effectsOf);
}
isNodeInteractive(nodeKey: string): boolean {
return this.graph.isInteractive(nodeKey);
}
/** Move an operation to 0 or 1, animated unless motion is disabled. */
private async settle(id: number, target: 0 | 1, animate: boolean) {
const operation = this.graph.operation(id);
if (!operation) return;
/**
* Move an operation to 0 or 1, animated unless motion is disabled. The
* spring stops early once `current()` reports the release was caught or
* superseded.
*/
private async settle(
operation: MutableOriginOperation,
target: 0 | 1,
animate: boolean,
current: () => boolean,
) {
if (!animate || prefersReducedMotion()) {
operation.progress = target;
operation.overshoot = 0;
operation.velocity = 0;
return;
}
const from = { position: operation.progress, velocity: operation.velocity };
await runSpring(from, target, (progress, velocity) => {
const live = this.graph.operation(id);
if (!live) return false;
live.progress = progress;
live.velocity = velocity;
// Start from the raw position so a rubber-banded drag springs back
// continuously instead of snapping its overshoot away.
const from = {
position: operation.progress + operation.overshoot,
velocity: operation.velocity,
};
await runSpring(
from,
target,
(position, velocity) => {
if (!this.graph.operation(operation.id) || !current()) return false;
operation.progress = clamp01(position);
operation.overshoot = position - operation.progress;
operation.velocity = velocity;
return true;
});
},
operation.choreography.spring,
);
}
/** Development only: each visible host's rendered transform and opacity. */
private capturePoses(): Map<string, Pose> {
const poses = new Map<string, Pose>();
for (const [key, host] of this.hosts) {
const pose = poseOf(host);
if (pose) poses.set(key, pose);
}
return poses;
}
/**
* Development only: warn when an operation's first frame moved a node that
* was visibly at rest, the one-frame "pop" of mismatched choreographies.
*/
private warnOnJump(
before: Map<string, Pose>,
operation: MutableOriginOperation,
) {
const { choreography } = operation;
if (warnedChoreographies.has(choreography)) return;
for (const [key, pose] of before) {
const host = this.hosts.get(key);
const after = host && poseOf(host);
if (!after || posesMatch(pose, after)) continue;
warnedChoreographies.add(choreography);
warn(
`"${choreography.name ?? "anonymous"}" moved node "${key}" on its ` +
"first frame. At progress 0 its effects should match the pose the " +
"scene was resting in, usually the previous choreography at " +
"progress 1; otherwise the view jumps.",
);
return;
}
}
private measure(nodeKey: string): OriginRect | undefined {
@@ -392,10 +584,40 @@ class Scene implements OriginScene {
return host ? measureRect(host) : undefined;
}
/** Scene-container bounds, or the browser window before mounting. */
private viewport(): OriginRect {
return this.container ? measureRect(this.container) : windowRect();
/** Re-measure the container, replacing the cached rect only on change. */
private refreshViewport(): void {
const next = this.container ? measureRect(this.container) : windowRect();
const current = this.viewport.value;
if (
next.top !== current.top ||
next.left !== current.left ||
next.width !== current.width ||
next.height !== current.height
)
this.viewport.value = next;
}
private readonly effectsOf = (
operation: MutableOriginOperation,
): OriginEffectSet => {
let effects = this.effectSets.get(operation);
if (!effects) {
effects = computed(() =>
operation.choreography.effects({
progress: operation.progress,
overshoot: operation.overshoot,
velocity: operation.velocity,
phase: operation.phase,
intent: operation.intent,
originRect: operation.originRect,
targetRect: operation.targetRect,
viewport: this.viewport.value,
}),
);
this.effectSets.set(operation, effects);
}
return effects.value;
};
}
/**
@@ -491,7 +713,10 @@ export const originNodeScopeKey: InjectionKey<OriginNodeScope> =
export function useOrigin(): UseOrigin {
const scope = inject(originNodeScopeKey);
if (!scope)
throw new Error("useOrigin() must be called inside an <OriginScene> view.");
throw new OriginSceneError(
"outside-scene",
"useOrigin() must be called inside an <OriginScene> view.",
);
const { scene, nodeKey } = scope;
const context = computed(() => scene.contextFor(nodeKey));
+7 -2
View File
@@ -68,6 +68,8 @@ export interface OriginOperation {
readonly history: OriginHistoryMode;
/** Normalized progress in the inclusive range `0..1`. */
readonly progress: number;
/** Drag distance past either end of `progress`; usually `0`. */
readonly overshoot: number;
/** Latest normalized velocity in progress units per second. */
readonly velocity: number;
/** Current operation lifecycle phase. */
@@ -126,8 +128,9 @@ export interface OriginOperationHandle {
/**
* Set interactive progress and optional velocity.
*
* Progress is clamped to `0..1`. Velocity is normalized to progress units
* per second and is used by the choreography's flick threshold.
* Progress is clamped to `0..1`; any excess is reported to choreographies as
* `overshoot`. Velocity is normalized to progress units per second and is
* used by the choreography's flick threshold.
*/
update(progress: number, velocity?: number): void;
/**
@@ -212,6 +215,8 @@ export interface MutableOriginOperation {
history: OriginHistoryMode;
/** Normalized progress. */
progress: number;
/** Drag distance past either end of `progress`. */
overshoot: number;
/** Normalized velocity. */
velocity: number;
/** Current lifecycle phase. */
+13
View File
@@ -50,3 +50,16 @@ export function prefersReducedMotion(): boolean {
Boolean(window.matchMedia?.("(prefers-reduced-motion: reduce)").matches)
);
}
/** Report a recoverable problem to developers without interrupting the UI. */
export function warn(message: string, error?: unknown): void {
if (typeof console !== "undefined")
console.warn(`[native-vue-router] ${message}`, ...(error ? [error] : []));
}
// Bundlers replace `process.env.NODE_ENV` in dependencies at build time, the
// same convention Vue and Vue Router use for their development-only checks.
declare const process: { env: { NODE_ENV?: string } };
/** Whether development-only checks and warnings are enabled. */
export const DEV: boolean = process.env.NODE_ENV !== "production";
+17
View File
@@ -30,6 +30,23 @@
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) {
.nvo-node {
will-change: auto;
@@ -13,7 +13,7 @@ import {
useOriginGesture,
type OriginGestureBinding,
type OriginGestureCompletionContext,
} from ".";
} from "../src";
const mountedApps: Array<ReturnType<typeof createApp>> = [];
@@ -19,7 +19,7 @@ import {
originView,
useOriginGesture,
type OriginGestureDefinition,
} from ".";
} from "../src";
const apps: Array<ReturnType<typeof createApp>> = [];
afterEach(() => {
@@ -247,6 +247,32 @@ describe(".onCommit()", () => {
}
});
it("runs synchronously inside the pointer-up event", async () => {
let calledDuringDispatch = false;
let dispatching = false;
const { host } = await mount(
gesture.to
.left()
.navigate(() => forward(originView(Target)))
.onCommit(() => {
calledDuringDispatch = dispatching;
})
.animate(motion),
);
host.dispatchEvent(pointer("pointerdown", 190, 0));
host.dispatchEvent(pointer("pointermove", 180, 500));
await flush();
host.dispatchEvent(pointer("pointermove", 40, 1000));
await flush();
// No awaiting between the event and the handler: iOS only allows
// haptics, audio, and clipboard writes during the user's gesture.
dispatching = true;
host.dispatchEvent(pointer("pointerup", 40, 1000));
dispatching = false;
expect(calledDuringDispatch).toBe(true);
});
it("fires at release, before the settling animation finishes", async () => {
const frames: FrameRequestCallback[] = [];
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) =>
+400
View File
@@ -0,0 +1,400 @@
import {
createApp,
defineComponent,
h,
nextTick,
shallowRef,
type Component,
} from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
allOf,
anyOf,
atScrollEdge,
createOriginScene,
defineOriginChoreography,
forward,
gesture,
nearEdge,
OriginGesture,
OriginGestureSurface,
OriginScene,
originView,
startedIn,
type OriginChoreographyContext,
type OriginGestureDefinition,
type OriginGestureStartContext,
} from "../src";
import { DirectionalTracker } from "../src/gesture/recognizer";
import { stepSpring } from "../src/scene/scene";
const apps: Array<ReturnType<typeof createApp>> = [];
afterEach(() => {
for (const app of apps.splice(0)) app.unmount();
document.body.innerHTML = "";
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
const contexts: OriginChoreographyContext[] = [];
const motion = defineOriginChoreography({
name: "v2-test",
effects: (context) => {
contexts.push(context);
return { target: { opacity: context.progress } };
},
});
const Target: Component = defineComponent({ render: () => h("div", "T") });
const toTarget = () => forward(originView(Target));
function pointer(
type: string,
clientX: number,
clientY: number,
timeStamp: number,
) {
const event = new PointerEvent(type, {
bubbles: true,
cancelable: true,
button: 0,
isPrimary: true,
pointerId: 1,
clientX,
clientY,
});
Object.defineProperty(event, "timeStamp", { value: timeStamp });
return event;
}
function touchmove() {
return new TouchEvent("touchmove", { bubbles: true, cancelable: true });
}
async function flush() {
for (let i = 0; i < 4; i += 1) await new Promise((r) => setTimeout(r, 0));
}
async function mount(root: Component) {
vi.stubGlobal("matchMedia", () => ({ matches: true }));
const scene = createOriginScene({ initial: originView(root) });
const element = document.createElement("div");
document.body.append(element);
const app = createApp({ render: () => h(OriginScene, { scene }) });
apps.push(app);
app.mount(element);
await nextTick();
const host = element.querySelector("#host") as HTMLElement;
Object.defineProperties(host, {
clientWidth: { configurable: true, value: 200 },
clientHeight: { configurable: true, value: 400 },
});
host.getBoundingClientRect = () =>
({ top: 0, left: 0, width: 200, height: 400 }) as DOMRect;
return { scene, host };
}
const hostFor = (
definition: OriginGestureDefinition,
children?: () => unknown,
) =>
defineComponent({
setup: () => () =>
h(OriginGesture, { id: "host", gesture: definition }, () =>
children ? children() : "host",
),
});
describe("recognition and native scrolling", () => {
const leftward = gesture.to.left().navigate(toTarget).animate(motion);
it("leaves scrolling to the browser through touch-action alone", async () => {
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("pointermove", 140, 101, 16));
await flush();
const move = touchmove();
host.dispatchEvent(move);
// 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");
});
});
describe("scroll edges", () => {
const scroller = shallowRef<HTMLElement | null>(null);
const dismiss = gesture.from
.scrollEdge(scroller, "top")
.to.down()
.navigate(toTarget)
.animate(motion);
async function mountWithScroller(scrollTop: number) {
const Root = hostFor(dismiss, () =>
h("div", {
id: "list",
ref: (element) => (scroller.value = element as HTMLElement | null),
}),
);
const mounted = await mount(Root);
let top = scrollTop;
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 };
}
describe("with directional touch-action", () => {
it("reserves only the dismiss pan while the list is at the top", async () => {
const { host, scrollTo } = await mountWithScroller(0);
// Dragging up still scrolls natively; dragging down is the gesture's.
expect(host.style.touchAction).toBe("pan-x pan-down");
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);
});
});
describe("without directional touch-action (Safari)", () => {
beforeEach(() => {
vi.stubGlobal("CSS", { supports: () => false });
});
it("keeps the axis pannable and claims touches from JavaScript", async () => {
const atTop = await mountWithScroller(0);
expect(atTop.host.style.touchAction).toBe("manipulation");
atTop.host.dispatchEvent(pointer("pointerdown", 100, 100, 0));
// The browser's first cancelable touchmove arrives after its slop.
atTop.host.dispatchEvent(pointer("pointermove", 101, 118, 16));
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);
});
});
});
describe("start-rule helpers", () => {
const host = document.createElement("div");
host.innerHTML = `<div class="handle"><span id="inner"></span></div><p id="body"></p>`;
const context = (target: Element, localX = 100): OriginGestureStartContext =>
({
event: { target } as unknown as PointerEvent,
origin: {} as never,
host,
bounds: { top: 0, left: 0, width: 200, height: 400 },
point: { clientX: localX, clientY: 10, localX, localY: 10 },
}) as OriginGestureStartContext;
it("matches pointer-down inside a selector", () => {
expect(startedIn(".handle")(context(host.querySelector("#inner")!))).toBe(
true,
);
expect(startedIn(".handle")(context(host.querySelector("#body")!))).toBe(
false,
);
});
it("composes with anyOf and allOf", () => {
const body = host.querySelector("#body")!;
const nearLeft = nearEdge("left", 40);
expect(anyOf(startedIn(".handle"), nearLeft)(context(body, 20))).toBe(true);
expect(anyOf(startedIn(".handle"), nearLeft)(context(body, 100))).toBe(
false,
);
expect(allOf(startedIn("p"), nearLeft)(context(body, 20))).toBe(true);
expect(allOf(startedIn("p"), nearLeft)(context(body, 100))).toBe(false);
});
it("checks scroll edges, treating a missing scroller as at the edge", () => {
const list = document.createElement("div");
Object.defineProperties(list, {
scrollTop: { value: 0 },
clientHeight: { value: 100 },
scrollHeight: { value: 300 },
});
const body = host.querySelector("#body")!;
expect(atScrollEdge(list, "top")(context(body))).toBe(true);
expect(atScrollEdge(list, "bottom")(context(body))).toBe(false);
expect(atScrollEdge(null, "top")(context(body))).toBe(true);
expect(atScrollEdge(".missing", "top")(context(body))).toBe(true);
});
});
describe("progress", () => {
it("measures against a custom distance and reports overshoot", async () => {
contexts.length = 0;
const drawer = gesture.to
.right({ distance: 100 })
.navigate(toTarget)
.animate(motion);
const { scene, host } = await mount(hostFor(drawer));
host.dispatchEvent(pointer("pointerdown", 0, 100, 0));
host.dispatchEvent(pointer("pointermove", 50, 100, 16));
await flush();
host.dispatchEvent(pointer("pointermove", 130, 100, 32));
await flush();
// Progress is 130 / 100 on a 200px host: clamped to 1, 0.3 overshoot.
expect(scene.operations.value[0]).toMatchObject({ progress: 1 });
expect(scene.operations.value[0]!.overshoot).toBeCloseTo(0.3);
scene.styleForNode(scene.operations.value[0]!.targetKey);
expect(contexts.at(-1)!.overshoot).toBeCloseTo(0.3);
host.dispatchEvent(pointer("pointermove", -20, 100, 48));
await flush();
expect(scene.operations.value[0]).toMatchObject({ progress: 0 });
expect(scene.operations.value[0]!.overshoot).toBeCloseTo(-0.2);
});
it("averages velocity over recent samples instead of the last step", () => {
const tracker = new DirectionalTracker(
"right",
{ clientX: 0, clientY: 0, timeStamp: 0 },
() => 100,
);
// Steady 1px/ms with one jittery, late sample.
for (const [x, t] of [
[16, 16],
[32, 32],
[48, 48],
[52, 50],
[80, 80],
] as const)
tracker.sample({ clientX: x, clientY: 0, timeStamp: t });
expect(tracker.velocity).toBeCloseTo(10, 0); // 1px/ms over 100px.
// Held still for longer than the window: the flick is over.
tracker.sample({ clientX: 80, clientY: 0, timeStamp: 300 });
expect(tracker.velocity).toBe(0);
});
});
describe("springs", () => {
it("uses the choreography's stiffness and damping", () => {
const soft = stepSpring({ position: 0, velocity: 0 }, 1, 16, {
stiffness: 80,
});
const stiff = stepSpring({ position: 0, velocity: 0 }, 1, 16, {
stiffness: 600,
});
expect(stiff.position).toBeGreaterThan(soft.position);
const unchanged = stepSpring({ position: 0, velocity: 0 }, 1, 16);
expect(unchanged.position).toBeGreaterThan(soft.position);
});
});
describe(".onArm() and .onDisarm()", () => {
it("fire when the drag crosses the commit threshold", async () => {
const events: string[] = [];
const definition = gesture.to
.left()
.navigate(toTarget)
.onArm(({ progress }) => events.push(`arm ${progress.toFixed(2)}`))
.onDisarm(({ progress }) => events.push(`disarm ${progress.toFixed(2)}`))
.animate(motion);
const { host } = await mount(hostFor(definition));
host.dispatchEvent(pointer("pointerdown", 190, 100, 0));
for (const [x, t] of [
[180, 16],
[160, 32],
[130, 48],
[100, 64],
[150, 80],
[90, 96],
] as const) {
host.dispatchEvent(pointer("pointermove", x, 100, t));
await flush();
}
// Default arming is distance-based: 25% of the 200px host is 50px.
expect(events).toEqual(["arm 0.30", "disarm 0.20", "arm 0.50"]);
});
});
+555
View File
@@ -0,0 +1,555 @@
/**
* Property tests: random operation sequences and pointer streams must keep
* the scene's structural invariants after every step, and must never leave
* an operation stuck once all pointers are released.
*/
import { createApp, defineComponent, h, nextTick, type Component } from "vue";
import { afterEach, describe, expect, it } from "vitest";
import {
above,
back,
createOriginScene,
defineOriginChoreography,
fade,
forward,
gesture,
OriginGestureSurface,
OriginScene,
OriginSceneError,
originView,
replace,
slideLeft,
slideRight,
under,
type OriginAction,
type OriginOperationHandle,
} from "../src";
import type { SceneGraph } from "../src/scene/graph";
import type { OriginScene as Scene } from "../src/scene/types";
const SEEDS = Number(process.env.PROPERTY_SEEDS ?? 60);
// Support -------------------------------------------------------------------
function rng(seed: number) {
let a = seed >>> 0;
const next = () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
return {
next,
int: (n: number) => Math.floor(next() * n),
pick: <T>(items: readonly T[]) => items[Math.floor(next() * items.length)]!,
chance: (p: number) => next() < p,
};
}
/** Deterministic `requestAnimationFrame` and clock. */
function installFrames() {
let now = 1000;
let queue: FrameRequestCallback[] = [];
window.requestAnimationFrame = ((cb: FrameRequestCallback) =>
queue.push(cb)) as typeof window.requestAnimationFrame;
performance.now = () => now;
return {
advance(ms = 16) {
now += ms;
const run = queue;
queue = [];
for (const cb of run) cb(now);
},
drain() {
for (let i = 0; i < 1000 && queue.length; i += 1) this.advance();
},
};
}
async function flush(rounds = 4) {
for (let i = 0; i < rounds; i += 1)
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
const component = (name: string): Component =>
defineComponent({ name, render: () => h("div", name) });
/** One-line summary of a scene, for failure traces. */
const describeScene = (scene: Scene) => {
const nodes = scene.nodes.value
.map(
(n) =>
`${n.key}:${n.state}:in=${n.incomingOperationId ?? "-"}:prev=${n.previousNodeKey ?? "-"}`,
)
.join(" ");
const operations = scene.operations.value
.map(
(o) =>
`#${o.id} ${o.originKey}→${o.targetKey} ${o.history} ${o.phase}/${o.intent}`,
)
.join(", ");
return ` roots=[${scene.roots.value}] nodes=[${nodes}] ops=[${operations}]`;
};
const graphOf = (scene: Scene) =>
(scene as unknown as { graph: SceneGraph }).graph;
/** Structural invariants that must hold after every step. */
function checkInvariants(scene: Scene, label: string, rootCount: number) {
const graph = graphOf(scene);
const nodes = [...graph.nodes.values()];
const roots = scene.roots.value;
const fail = (message: string) => {
throw new Error(`${label}\n${describeScene(scene)}\n${message}`);
};
if (roots.length !== rootCount)
fail(`expected ${rootCount} roots, found ${roots.length}`);
if (new Set(roots).size !== roots.length) fail("duplicate roots");
for (const key of roots) {
const node = graph.node(key);
if (!node) fail(`root ${key} does not exist`);
else if (graph.incomingOf(node)) fail(`root ${key} has an incoming edge`);
}
for (const node of nodes) {
// Incoming edges point back at their node.
if (node.incomingOperationId !== undefined) {
const incoming = graph.operation(node.incomingOperationId);
if (!incoming) fail(`${node.key} has a dangling incoming edge`);
else if (incoming.targetKey !== node.key)
fail(`${node.key}'s incoming edge targets ${incoming.targetKey}`);
}
// History links exist and are acyclic.
const seen = new Set([node.key]);
for (let key = node.previousNodeKey; key;) {
const previous = graph.node(key);
if (!previous) fail(`${node.key} has a missing previous entry ${key}`);
if (seen.has(key)) fail(`${node.key} has cyclic history`);
seen.add(key);
key = previous?.previousNodeKey;
}
if (node.state === "parked") {
if (roots.includes(node.key)) fail(`parked ${node.key} is a root`);
if (node.incomingOperationId !== undefined)
fail(`parked ${node.key} is positioned by an edge`);
if (scene.isNodeInteractive(node.key)) fail(`parked ${node.key} is live`);
if (scene.styleForNode(node.key).visibility !== "hidden")
fail(`parked ${node.key} is visible`);
continue;
}
// Every presented node reaches a root through acyclic incoming edges.
const visited = new Set<string>();
let current = node;
for (;;) {
if (visited.has(current.key)) fail(`${node.key} is in a visual cycle`);
visited.add(current.key);
const incoming = graph.incomingOf(current);
if (!incoming) break;
const origin = graph.node(incoming.originKey);
if (!origin) fail(`${current.key}'s edge comes from a missing origin`);
current = origin!;
}
if (!roots.includes(current.key))
fail(`${node.key} is detached from every root`);
if (node.state === "exposed" && scene.isNodeInteractive(node.key))
fail(`exposed ${node.key} is live`);
}
// Every live operation joins existing nodes; one per origin.
const origins = new Set<string>();
for (const operation of scene.operations.value) {
if (!graph.node(operation.originKey))
fail(`operation ${operation.id} has a missing origin`);
if (!graph.node(operation.targetKey))
fail(`operation ${operation.id} has a missing target`);
if (origins.has(operation.originKey))
fail(`${operation.originKey} has two live operations`);
origins.add(operation.originKey);
}
}
/** Additional invariants once every operation has finished. */
function checkResting(scene: Scene, rootCount: number, label: string) {
checkInvariants(scene, label, rootCount);
const states = scene.nodes.value.map((node) => node.state);
expect(scene.operations.value, `${label}: live operations`).toEqual([]);
expect(states, `${label}: states`).not.toContain("transitioning");
expect(
states.filter((state) => state === "active"),
`${label}: one active entry per root`,
).toHaveLength(rootCount);
}
// Scene operations ----------------------------------------------------------
const drawerOpen = defineOriginChoreography({
name: "drawer-open",
persistAtRest: true,
effects: ({ progress }) => ({
source: { transform: `translateX(${progress * 66}%)` },
target: { transform: `translateX(${(progress - 1) * 66}%)` },
}),
});
const drawerClose = defineOriginChoreography({
name: "drawer-close",
commitThreshold: 0.2,
commitVelocity: 0.5,
effects: ({ progress }) => ({
source: { transform: `translateX(${-progress * 66}%)` },
}),
});
const choreographies = [slideLeft, slideRight, fade, drawerOpen, drawerClose];
const views = ["A", "B", "C", "D"].map((name) =>
originView(component(name), undefined, { key: name.toLowerCase() }),
);
function randomAction(r: ReturnType<typeof rng>): OriginAction {
const choreography = r.pick(choreographies);
const target = r.pick(views);
const placement = r.pick(["above", "under", undefined] as const);
switch (r.int(6)) {
case 0:
return forward(target, choreography, { placement });
case 1:
return replace(target, choreography, { placement });
case 2:
case 3:
return back(choreography, { placement });
case 4:
return above(target, choreography, {
history: r.pick(["push", "replace", "back"] as const),
});
default:
return under(target, choreography);
}
}
async function runOperations(seed: number) {
const r = rng(seed);
const frames = installFrames();
const initial = seed % 3 === 0 ? [views[0]!, views[1]!] : [views[0]!];
const scene = createOriginScene({ initial });
const handles: OriginOperationHandle[] = [];
const unexpected: unknown[] = [];
const expectSceneError = (error: unknown) => {
if (!(error instanceof OriginSceneError)) unexpected.push(error);
};
const trace: string[] = [];
for (let step = 0; step < 80; step += 1) {
const label = () =>
`seed ${seed} step ${step}\n${trace.slice(-60).join("\n")}`;
const roll = r.next();
const liveIds = new Set(scene.operations.value.map(({ id }) => id));
const live = handles.filter(({ id }) => liveIds.has(id));
const pick = () =>
live.length && r.chance(0.85) ? r.pick(live) : r.pick(handles);
if (roll < 0.3 || handles.length === 0) {
const originKey = r.pick(scene.nodes.value).key;
const action = randomAction(r);
scene.begin(originKey, action).then(
(handle) => {
handles.push(handle);
trace.push(
` begin #${handle.id} ${originKey}→${handle.targetKey} ${action.history} ${action.choreography.name}`,
);
},
(error) => {
trace.push(
` begin ${originKey} ${action.history} rejected: ${error.code}`,
);
expectSceneError(error);
},
);
} else if (roll < 0.55) {
const handle = pick();
const p = r.next() * 1.4 - 0.2;
const v = r.next() * 3 - 1;
trace.push(`update #${handle.id} ${p.toFixed(2)} ${v.toFixed(2)}`);
handle.update(p, v);
} else if (roll < 0.75) {
const handle = pick();
const options = {
commit: r.pick([true, false, undefined]),
animate: r.chance(0.5),
};
trace.push(`finish #${handle.id} ${JSON.stringify(options)}`);
void handle
.finish(options)
.then((v) => trace.push(` finished #${handle.id} → ${v}`));
} else if (roll < 0.82) {
const handle = pick();
trace.push(`cancel #${handle.id}`);
void handle
.cancel({ animate: r.chance(0.5) })
.then(() => trace.push(` cancelled #${handle.id}`));
} else {
trace.push("frames");
for (let i = 1 + r.int(12); i > 0; i -= 1) frames.advance(4 + r.int(40));
}
if (!r.chance(0.15)) await flush();
trace.push(describeScene(scene));
checkInvariants(scene, label(), initial.length);
}
// Release everything: every live operation finishes, then springs drain.
await flush();
for (const handle of handles) void handle.finish({ animate: r.chance(0.5) });
await flush();
frames.drain();
await flush();
checkResting(
scene,
initial.length,
`seed ${seed} at rest\n${trace.slice(-50).join("\n")}`,
);
expect(unexpected, `seed ${seed}: non-scene errors`).toEqual([]);
}
describe("scene properties", () => {
for (let seed = 1; seed <= SEEDS; seed += 1)
it(`random operations ${seed}`, () => runOperations(seed));
});
// Gestures ------------------------------------------------------------------
const apps: Array<ReturnType<typeof createApp>> = [];
afterEach(() => {
for (const app of apps.splice(0)) app.unmount();
document.body.innerHTML = "";
});
const WIDTH = 300;
const HEIGHT = 600;
function stubBounds(element: HTMLElement) {
if (element.dataset.stubbed) return;
element.dataset.stubbed = "1";
Object.defineProperties(element, {
clientWidth: { configurable: true, value: WIDTH },
clientHeight: { configurable: true, value: HEIGHT },
});
element.getBoundingClientRect = () =>
({ top: 0, left: 0, width: WIDTH, height: HEIGHT }) as DOMRect;
}
/** Views whose gestures can grow, collapse, and race chains of operations. */
function gestureWorld() {
const viewsByName: Record<string, ReturnType<typeof originView>> = {};
const slide = defineOriginChoreography({
name: "slide",
commitVelocity: 0.6,
effects: ({ progress }) => ({
source: { transform: `translateX(${-progress * 100}px)` },
target: { transform: `translateX(${(1 - progress) * 100}px)` },
}),
});
const surface = (name: string, gestures: () => unknown[]) =>
defineComponent({
name,
setup: () => () =>
h(
OriginGestureSurface,
{ gestures: gestures() as never, "data-gesture-host": "" },
() => [h("input", { "data-input": "" }), name],
),
});
const A = surface("A", () => [
gesture.to
.left()
.navigate(async () => {
await Promise.resolve();
return forward(viewsByName.B!);
})
.animate(slide),
gesture.from
.left(40)
.to.right()
.navigate((context) => (context.canGoBack ? back() : null))
.animate(slideRight),
gesture.from
.when(({ point }) => point.localY < HEIGHT / 2)
.to.down({ threshold: 12 })
.complete(({ progress }) => progress > 0.2)
.navigate(() => forward(viewsByName.C!, { placement: "under" }))
.animate(drawerOpen),
gesture.from
.bottom("20%")
.to.up()
.navigate((context) =>
context.canGoBack ? back() : replace(viewsByName.A!),
)
.animate(fade),
]);
const B = surface("B", () => [
gesture.to
.left({ threshold: 6 })
.navigate(() => forward(viewsByName.C!))
.animate(slide),
gesture.to
.right({ threshold: 6 })
.navigate((context) => (context.canGoBack ? back() : null))
.animate(slideRight),
]);
const C = surface("C", () => [
gesture.to
.up({ threshold: 4 })
.navigate((context) =>
context.canGoBack ? back() : forward(viewsByName.B!),
)
.animate(drawerClose),
gesture.to
.left()
.navigate(() => {
throw new Error("intentional navigate failure");
})
.animate(slide),
]);
viewsByName.A = originView(A, undefined, { key: "a" });
viewsByName.B = originView(B, undefined, { key: "b" });
viewsByName.C = originView(C, undefined, { key: "c" });
const scene = createOriginScene({ initial: viewsByName.A });
const root = document.createElement("div");
document.body.append(root);
const app = createApp({ render: () => h(OriginScene, { scene }) });
apps.push(app);
app.mount(root);
return { scene, root };
}
function pointerEvent(type: string, init: PointerEventInit, timeStamp: number) {
const event = new PointerEvent(type, {
bubbles: true,
cancelable: true,
...init,
});
Object.defineProperty(event, "timeStamp", { value: timeStamp });
return event;
}
async function runPointers(seed: number) {
const r = rng(seed * 7919);
const frames = installFrames();
const warn = console.warn;
console.warn = () => {};
try {
const { scene, root } = gestureWorld();
await nextTick();
let time = 100;
let x = 0;
let y = 0;
let pointerId = 1;
let host: HTMLElement | null = null;
let heading: "left" | "right" | "up" | "down" = "left";
const trace: string[] = [];
const dispatch = (
type: string,
init: PointerEventInit,
onInput = false,
) => {
trace.push(
`${type} ${host?.closest("[data-origin-node]")?.getAttribute("data-origin-node")} ${JSON.stringify(init)} t=${time}${host?.isConnected ? "" : " (detached)"}`,
);
if (!host?.isConnected) return;
const target = onInput
? (host.querySelector("[data-input]") as HTMLElement)
: host;
target.dispatchEvent(pointerEvent(type, init, time));
};
for (let step = 0; step < 120; step += 1) {
time += r.int(40);
const roll = r.next();
if (roll < 0.15) {
// A new primary pointer means the previous one ended: a browser
// delivers its pointer-up to the captured host, or, if that was
// lost, fires lostpointercapture there.
dispatch(r.chance(0.8) ? "pointerup" : "lostpointercapture", {
clientX: x,
clientY: y,
pointerId,
});
if (r.chance(0.8)) await flush(3);
const nodes = scene.nodes.value.filter((node) =>
scene.isNodeInteractive(node.key),
);
const node = r.pick(nodes.length ? nodes : scene.nodes.value);
host = root.querySelector(
`[data-origin-node="${CSS.escape(node.key)}"] [data-gesture-host]`,
);
if (host) stubBounds(host);
x = r.chance(0.3) ? r.int(50) : r.int(WIDTH);
y = r.chance(0.3) ? HEIGHT - r.int(130) : r.int(HEIGHT);
pointerId = r.chance(0.8) ? 1 : 2;
heading = r.pick(["left", "right", "up", "down"] as const);
dispatch(
"pointerdown",
{
clientX: x,
clientY: y,
pointerId,
button: r.chance(0.95) ? 0 : 1,
isPrimary: r.chance(0.95),
},
r.chance(0.08),
);
} else if (roll < 0.65) {
const amount = 5 + r.int(55);
if (heading === "left") x -= amount;
if (heading === "right") x += amount;
if (heading === "up") y -= amount;
if (heading === "down") y += amount;
dispatch("pointermove", { clientX: x, clientY: y, pointerId });
} else if (roll < 0.8) {
dispatch("pointerup", { clientX: x, clientY: y, pointerId });
} else if (roll < 0.85) {
dispatch(r.pick(["pointercancel", "lostpointercapture"]), {
pointerId,
});
} else {
trace.push("frames");
for (let i = 1 + r.int(20); i > 0; i -= 1) frames.advance(16);
}
if (r.chance(0.7)) {
frames.advance(16);
frames.advance(16);
}
if (!r.chance(0.2)) await flush(3);
trace.push(describeScene(scene));
checkInvariants(
scene,
`seed ${seed} step ${step}\n${trace.slice(-40).join("\n")}`,
1,
);
}
// Lift the pointer; nothing may remain stuck mid-transition.
dispatch("pointerup", { clientX: x, clientY: y, pointerId });
await flush();
frames.drain();
await flush();
trace.push(describeScene(scene));
checkResting(
scene,
1,
`seed ${seed} after release\n${trace.slice(-40).join("\n")}`,
);
} finally {
console.warn = warn;
}
}
describe("gesture properties", () => {
for (let seed = 1; seed <= SEEDS; seed += 1)
it(`random pointer stream ${seed}`, () => runPointers(seed));
});
+361
View File
@@ -0,0 +1,361 @@
import {
createApp,
defineComponent,
h,
nextTick,
shallowRef,
type Component,
} from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
back,
createOriginScene,
defineOriginChoreography,
forward,
gesture,
OriginGesture,
OriginGestureSurface,
OriginScene,
OriginSceneError,
originView,
type OriginGestureDefinition,
} from "../src";
import { shouldCommit } from "../src/scene/graph";
const apps: Array<ReturnType<typeof createApp>> = [];
afterEach(() => {
for (const app of apps.splice(0)) app.unmount();
document.body.innerHTML = "";
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
const motion = defineOriginChoreography({
name: "recognizer-test",
effects: ({ progress }) => ({ target: { opacity: progress } }),
});
const Target: Component = defineComponent({ render: () => h("div", "T") });
const toTarget = () => forward(originView(Target));
function pointer(
type: string,
clientX: number,
timeStamp: number,
pointerId = 1,
) {
const event = new PointerEvent(type, {
bubbles: true,
cancelable: true,
button: 0,
isPrimary: true,
pointerId,
clientX,
clientY: 100,
});
Object.defineProperty(event, "timeStamp", { value: timeStamp });
return event;
}
async function flush() {
for (let i = 0; i < 4; i += 1) await new Promise((r) => setTimeout(r, 0));
}
function stubBounds(host: HTMLElement) {
Object.defineProperty(host, "clientWidth", { value: 200 });
host.getBoundingClientRect = () =>
({ top: 0, left: 0, width: 200, height: 400 }) as DOMRect;
}
/** Render `root` as the scene's initial view; return the scene and host. */
async function mount(root: Component) {
vi.stubGlobal("matchMedia", () => ({ matches: true }));
const scene = createOriginScene({ initial: originView(root) });
const element = document.createElement("div");
document.body.append(element);
const app = createApp({ render: () => h(OriginScene, { scene }) });
apps.push(app);
app.mount(element);
await nextTick();
const host = element.querySelector("#host") as HTMLElement;
stubBounds(host);
return { scene, host, app };
}
const gestureHost = (definition: () => OriginGestureDefinition) =>
defineComponent({
setup: () => () =>
h(OriginGesture, { id: "host", gesture: definition() }, () => "host"),
});
/** Press, then drag left far enough to capture and begin an operation. */
async function dragLeft(host: HTMLElement, pointerId = 1, x = 180) {
host.dispatchEvent(pointer("pointerdown", x, 0, pointerId));
host.dispatchEvent(pointer("pointermove", x - 60, 100, pointerId));
await flush();
}
describe("stuck gestures", () => {
const definition = gesture.to.left().navigate(toTarget).animate(motion);
it("cancels a captured gesture when pointer capture is lost", async () => {
const { scene, host } = await mount(gestureHost(() => definition));
await dragLeft(host);
expect(scene.operations.value).toHaveLength(1);
host.dispatchEvent(pointer("lostpointercapture", 120, 120));
await flush();
expect(scene.operations.value).toHaveLength(0);
expect(scene.nodes.value).toHaveLength(1);
});
it("ignores a child losing implicit touch capture to the host", async () => {
const Root = defineComponent({
setup: () => () =>
h(OriginGesture, { id: "host", gesture: definition }, () =>
h("span", { id: "child" }, "x"),
),
});
const { scene, host } = await mount(Root);
await dragLeft(host);
expect(scene.operations.value).toHaveLength(1);
// Taking capture on the host makes the touched child lose its implicit
// capture; that event bubbles to the host.
host
.querySelector("#child")!
.dispatchEvent(pointer("lostpointercapture", 120, 110));
await flush();
expect(scene.operations.value).toHaveLength(1);
});
it("ignores lostpointercapture after a normal release", async () => {
const { scene, host } = await mount(gestureHost(() => definition));
await dragLeft(host);
host.dispatchEvent(pointer("pointermove", 20, 200));
host.dispatchEvent(pointer("pointerup", 20, 200));
host.dispatchEvent(pointer("lostpointercapture", 20, 200));
await flush();
// Committed, not cancelled.
expect(scene.nodes.value.map((node) => node.state)).toEqual([
"parked",
"active",
]);
});
it("cancels a stale gesture when a new primary pointer goes down", async () => {
const { scene, host } = await mount(gestureHost(() => definition));
await dragLeft(host, 1);
expect(scene.operations.value).toHaveLength(1);
// pointer 1's pointer-up was lost; a new touch starts.
host.dispatchEvent(pointer("pointerdown", 150, 300, 2));
await flush();
expect(scene.operations.value).toHaveLength(0);
expect(scene.nodes.value).toHaveLength(1);
});
it("cancels a captured gesture when its component unmounts", async () => {
const show = shallowRef(true);
const Root = defineComponent({
setup: () => () =>
h("div", [
show.value
? h(OriginGesture, { id: "host", gesture: definition }, () => "x")
: null,
]),
});
const { scene, host } = await mount(Root);
await dragLeft(host);
expect(scene.operations.value).toHaveLength(1);
show.value = false;
await flush();
expect(scene.operations.value).toHaveLength(0);
});
});
describe("failure warnings", () => {
it("warns when navigate() throws, without touching the scene", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const failing = gesture.to
.left()
.navigate(() => {
throw new Error("no route");
})
.animate(motion);
const { scene, host } = await mount(gestureHost(() => failing));
await dragLeft(host);
expect(scene.operations.value).toHaveLength(0);
expect(warn).toHaveBeenCalledWith(
"[native-vue-router] A gesture's navigate() factory failed.",
new Error("no route"),
);
});
it("warns when the scene rejects the operation", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
// The initial view has no previous entry, so back() is rejected.
const invalid = gesture.to
.left()
.navigate(() => back())
.animate(motion);
const { host } = await mount(gestureHost(() => invalid));
await dragLeft(host);
expect(warn).toHaveBeenCalledTimes(1);
const error = warn.mock.calls[0]![1] as OriginSceneError;
expect(error).toBeInstanceOf(OriginSceneError);
expect(error.code).toBe("no-previous-entry");
});
it("stays quiet when two gestures race for the same origin", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const both = [
gesture.to.left({ threshold: 4 }).navigate(toTarget).animate(motion),
gesture.to.left({ threshold: 6 }).navigate(toTarget).animate(motion),
];
const Root = defineComponent({
setup: () => () =>
h(OriginGestureSurface, { id: "host", gestures: both }, () => "x"),
});
const { scene, host } = await mount(Root);
await dragLeft(host);
expect(scene.operations.value).toHaveLength(1);
expect(warn).not.toHaveBeenCalled();
});
it("declining with null does not warn", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const declined = gesture.to
.left()
.navigate(() => null)
.animate(motion);
const { host } = await mount(gestureHost(() => declined));
await dragLeft(host);
expect(warn).not.toHaveBeenCalled();
});
});
describe("reactive gesture props", () => {
it("applies a new definition from the next pointer-down only", async () => {
const leftward = gesture.to.left().navigate(toTarget).animate(motion);
const rightward = gesture.to.right().navigate(toTarget).animate(motion);
const current = shallowRef(leftward);
const { scene, host } = await mount(gestureHost(() => current.value));
expect(host.style.touchAction).toBe("pan-y");
// Swap mid-gesture: the drag in progress keeps the leftward definition.
host.dispatchEvent(pointer("pointerdown", 180, 0));
current.value = rightward;
await nextTick();
host.dispatchEvent(pointer("pointermove", 140, 100));
await flush();
expect(scene.operations.value).toHaveLength(1);
// A short, held release (20%) cancels, leaving the origin interactive.
host.dispatchEvent(pointer("pointerup", 140, 1000));
await flush();
// The next touch uses the rightward definition.
host.dispatchEvent(pointer("pointerdown", 20, 2000));
host.dispatchEvent(pointer("pointermove", 80, 2100));
await flush();
expect(scene.operations.value).toHaveLength(1);
});
it("keeps a gesture in progress across a parent re-render", async () => {
const tick = shallowRef(0);
// A new but equivalent definition object on every render.
const { scene, host } = await mount(
gestureHost(() => {
void tick.value;
return gesture.to.left().navigate(toTarget).animate(motion);
}),
);
await dragLeft(host);
tick.value += 1;
await nextTick();
host.dispatchEvent(pointer("pointermove", 20, 200));
host.dispatchEvent(pointer("pointerup", 20, 200));
await flush();
expect(scene.nodes.value.map((node) => node.state)).toEqual([
"parked",
"active",
]);
});
it("tracks a surface's changing gesture list and touch-action", async () => {
const list = shallowRef<OriginGestureDefinition[]>([
gesture.to.left().navigate(toTarget).animate(motion),
]);
const Root = defineComponent({
setup: () => () =>
h(
OriginGestureSurface,
{ id: "host", gestures: list.value },
() => "x",
),
});
const { scene, host } = await mount(Root);
expect(host.style.touchAction).toBe("pan-y");
list.value = [
...list.value,
gesture.to.down().navigate(toTarget).animate(motion),
];
await nextTick();
expect(host.style.touchAction).toBe("none");
host.dispatchEvent(
Object.assign(pointer("pointerdown", 100, 0), { clientY: 0 }),
);
const down = new PointerEvent("pointermove", {
bubbles: true,
isPrimary: true,
pointerId: 1,
clientX: 100,
clientY: 80,
});
host.dispatchEvent(down);
await flush();
expect(scene.operations.value).toHaveLength(1);
});
});
describe("commitVelocity", () => {
const operation = (commitVelocity: number | undefined, velocity: number) =>
({
progress: 0.1,
velocity,
choreography: { effects: () => ({}), commitVelocity },
}) as unknown as Parameters<typeof shouldCommit>[0];
it("defaults to 0.2", () => {
expect(shouldCommit(operation(undefined, 0.19), {})).toBe(false);
expect(shouldCommit(operation(undefined, 0.2), {})).toBe(true);
});
it("uses the choreography's value when supplied", () => {
expect(shouldCommit(operation(0.6, 0.5), {})).toBe(false);
expect(shouldCommit(operation(0.6, 0.6), {})).toBe(true);
expect(shouldCommit(operation(0.1, 0.15), {})).toBe(true);
});
it("lets a fast flick's direction veto the distance", () => {
const at = (progress: number, velocity: number) =>
({
progress,
velocity,
choreography: { effects: () => ({}) },
}) as unknown as Parameters<typeof shouldCommit>[0];
// Past the 0.36 threshold, but flicked back toward the start: cancels.
expect(shouldCommit(at(0.8, -0.5), {})).toBe(false);
// Barely started, but flicked forward: commits.
expect(shouldCommit(at(0.05, 0.5), {})).toBe(true);
// Slow releases fall back to distance, in either direction.
expect(shouldCommit(at(0.8, -0.1), {})).toBe(true);
expect(shouldCommit(at(0.2, 0.1), {})).toBe(false);
});
it("still yields to an explicit decision", () => {
expect(shouldCommit(operation(0.1, 5), { commit: false })).toBe(false);
});
});
@@ -18,7 +18,7 @@ import {
originView,
replace,
under,
} from ".";
} from "../src";
const mountedApps: Array<ReturnType<typeof createApp>> = [];
+1 -2
View File
@@ -9,6 +9,5 @@
"outDir": "dist",
"tsBuildInfoFile": "../../node_modules/.tmp/core-v2.tsbuildinfo"
},
"include": ["src/**/*.ts", "src/**/*.vue"],
"exclude": ["src/**/*.test.ts"]
"include": ["src/**/*.ts", "src/**/*.vue"]
}
+1
View File
@@ -1 +1,2 @@
.baseline/
browser/app/dist/
+32 -18
View File
@@ -1,27 +1,41 @@
# Differential verification
# Benchmarks
Checks a core-v2 implementation against an earlier one (by default `main`)
by driving both with identical randomized inputs and requiring identical
observable results after every step.
Compares core-v2's performance against an earlier implementation (by default
`main`).
From the repository root:
```sh
packages/core-v2/verify/setup-baseline.sh main # extract into verify/.baseline/
npx vitest run --config packages/core-v2/verify/vitest.config.ts
SEEDS=50 npx vitest run --config packages/core-v2/verify/vitest.config.ts # quicker
ONLY=rewrite npx vitest bench --run --config packages/core-v2/verify/vitest.config.ts
ONLY=baseline npx vitest bench --run --config packages/core-v2/verify/vitest.config.ts
ONLY=rewrite npx vitest bench --run --config packages/core-v2/verify/vitest.config.ts
```
- `scene.diff.ts` runs random `begin`/`update`/`finish`/`cancel`/animation
frame sequences against both scenes and compares nodes, operations, roots,
every node's composited style, interactivity, contexts, and every promise
result. It also compares action-helper output and the runtime export list.
- `gesture.diff.ts` mounts both packages in Vue apps whose views install the
same gestures (builder definitions, legacy options, `OriginGesture`, and
`OriginGestureSurface`), dispatches identical random pointer streams, and
compares scene state and every navigation, start, and completion callback.
- `scene.bench.ts` measures compositing, push/back cycles, and rendered
frames. Run each implementation in its own process with `ONLY=`; running
both in one process biases whichever runs second.
`scene.bench.ts` measures compositing, push/back cycles, and rendered frames.
Run each implementation in its own process with `ONLY=`; running both in one
process biases whichever runs second.
Behavioural verification lives with the unit tests: `tests/property.test.ts`
drives random operation sequences and pointer streams and checks the scene's
structural invariants after every step. Set `PROPERTY_SEEDS` to run more.
## Real-browser feel tests
`browser/` drives a development build in Chromium with emulated touch input,
which happy-dom cannot model: implicit touch pointer capture, native
scrolling, `pointercancel`, frame timing, and rendered styles. It checks:
- **recognition** on human-like traces: wobbly and diagonal starts, a small
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
npm run test:feel
```
Set `CHROMIUM_PATH` to use a Chromium other than Playwright's own.
@@ -0,0 +1,34 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<style>
html,
body,
#app {
margin: 0;
height: 100%;
overflow: hidden;
}
.page {
height: 100%;
display: flex;
flex-direction: column;
background: #fff;
}
.list {
flex: 1;
overflow: auto;
}
.row {
height: 80px;
border-bottom: 1px solid #ddd;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.ts"></script>
</body>
</html>
+141
View File
@@ -0,0 +1,141 @@
import { createApp, defineComponent, h, ref } from "vue";
import "../../../src/style.css";
import {
OriginGesture,
OriginGestureSurface,
OriginScene,
back,
createOriginScene,
defineOriginChoreography,
forward,
gesture,
originView,
slideLeft,
useOrigin,
} from "../../../src";
declare global {
interface Window {
log: string[];
inPointerup: boolean;
scene: ReturnType<typeof createOriginScene>;
scrollTopOf: (selector: string) => number;
begin: (name: "matched" | "mismatched") => Promise<void>;
}
}
window.log = [];
window.inPointerup = false;
window.addEventListener("pointerup", () => (window.inPointerup = true), true);
window.addEventListener("pointerup", () =>
setTimeout(() => (window.inPointerup = false)),
);
const warn = console.warn;
console.warn = (...args: unknown[]) => {
window.log.push("warn: " + String(args[0]));
warn(...args);
};
const rows = (n: number) =>
Array.from({ length: n }, (_, i) => h("div", { class: "row" }, `row ${i}`));
const Detail = defineComponent({
name: "Detail",
setup() {
const list = ref<HTMLElement | null>(null);
const dismiss = gesture.from
.scrollEdge(list, "top")
.to.down()
.navigate((c) => (c.canGoBack ? back() : null))
.onCommit(() => window.log.push("dismiss committed"))
.animate(
defineOriginChoreography({
name: "dismiss",
effects: ({ progress }) => ({
source: { transform: `translateY(${progress * 100}%)` },
}),
}),
);
return () =>
h(OriginGesture, { gesture: dismiss, class: "page", id: "detail" }, () =>
h("div", { class: "list", id: "detail-list", ref: list }, rows(40)),
);
},
});
const Home = defineComponent({
name: "Home",
setup() {
const open = gesture.to
.left()
.navigate(() => forward(originView(Detail, undefined, { key: "detail" })))
.onCommit(() =>
window.log.push(`open committed inPointerup=${window.inPointerup}`),
)
.animate(slideLeft);
return () =>
h(
OriginGestureSurface,
{ gestures: [open], class: "page", id: "home" },
() => h("div", { class: "list", id: "home-list" }, rows(40)),
);
},
});
// Continuity check pair: a drawer that rests at 80% and a close that starts
// either from the same pose (matched) or from 0 (mismatched).
const drawerOpen = defineOriginChoreography({
name: "drawer-open",
persistAtRest: true,
effects: ({ progress }) => ({
source: { transform: `translateX(${progress * 80}%)` },
}),
});
const closes = {
matched: defineOriginChoreography({
name: "drawer-close-matched",
effects: ({ progress }) => ({
target: { transform: `translateX(${(1 - progress) * 80}%)` },
}),
}),
mismatched: defineOriginChoreography({
name: "drawer-close-mismatched",
effects: ({ progress }) => ({
target: { transform: `translateX(${(1 - progress) * 50}%)` },
}),
}),
};
const Drawer = defineComponent({
name: "Drawer",
render: () => h("div", { class: "page" }, "drawer"),
});
const Plain = defineComponent({
name: "Plain",
setup() {
const origin = useOrigin();
(window as unknown as { openDrawer: () => Promise<boolean> }).openDrawer =
() =>
origin.perform(
forward(originView(Drawer, undefined, { key: "drawer" }), drawerOpen),
);
return () => h("div", { class: "page", id: "plain" }, "plain");
},
});
const params = new URLSearchParams(location.search);
const initial = params.get("view") === "plain" ? Plain : Home;
const scene = createOriginScene({
initial: originView(initial, undefined, { key: "root" }),
});
window.scene = scene;
window.scrollTopOf = (selector) =>
document.querySelector(selector)?.scrollTop ?? -1;
window.begin = async (name) => {
await (
window as unknown as { openDrawer: () => Promise<boolean> }
).openDrawer();
const drawerKey = scene.nodes.value.find((n) => n.view.key === "drawer")!.key;
const handle = await scene.begin(drawerKey, back(closes[name]));
await handle.cancel({ animate: false });
await handle.finish({ commit: false, animate: false });
};
createApp({ render: () => h(OriginScene, { scene }) }).mount("#app");
@@ -0,0 +1,8 @@
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite";
export default defineConfig({
root: import.meta.dirname,
base: "./",
plugins: [vue()],
build: { outDir: "dist", emptyOutDir: true, minify: false },
});
+256
View File
@@ -0,0 +1,256 @@
import { chromium } from "@playwright/test";
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
import { extname, join } from "node:path";
const root = join(import.meta.dirname, "app/dist");
const types = {
".html": "text/html",
".js": "text/javascript",
".css": "text/css",
};
const server = createServer(async (req, res) => {
const path = join(
root,
new URL(req.url, "http://x").pathname.replace(/\/$/, "/index.html"),
);
let body;
try {
body = await readFile(path);
} catch {
res.writeHead(404);
return res.end();
}
res.writeHead(200, {
"content-type": types[extname(path)] ?? "application/octet-stream",
});
res.end(body);
}).listen(0);
const base = `http://127.0.0.1:${server.address().port}/`;
// CHROMIUM_PATH overrides Playwright's bundled Chromium.
const browser = await chromium.launch({
executablePath: process.env.CHROMIUM_PATH,
});
const context = await browser.newContext({
hasTouch: true,
isMobile: true,
viewport: { width: 390, height: 844 },
});
const results = [];
const check = (name, ok, detail) => {
results.push({ name, ok, detail });
console.log(`${ok ? "PASS" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
};
async function open(query = "") {
const page = await context.newPage();
page.on("pageerror", (e) => console.log("pageerror", e.message));
await page.goto(base + query);
await page.waitForFunction(() => window.scene);
return { page, cdp: await context.newCDPSession(page) };
}
async function drag({ page, cdp }, from, to, steps = 12, stepMs = 16) {
await cdp.send("Input.dispatchTouchEvent", {
type: "touchStart",
touchPoints: [{ x: from[0], y: from[1] }],
});
for (let i = 1; i <= steps; i++) {
const x = from[0] + ((to[0] - from[0]) * i) / steps;
const y = from[1] + ((to[1] - from[1]) * i) / steps;
await cdp.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x, y }],
});
await page.waitForTimeout(stepMs);
}
await cdp.send("Input.dispatchTouchEvent", {
type: "touchEnd",
touchPoints: [],
});
await page.waitForTimeout(700);
}
const states = (page) =>
page.evaluate(() =>
window.scene.nodes.value.map((n) => `${n.view.key}:${n.state}`).join(" "),
);
const log = (page) => page.evaluate(() => window.log.slice());
// 1. Horizontal swipe over a vertical scroller commits (implicit capture + no pointercancel).
{
const t = await open();
await drag(t, [340, 400], [60, 405]);
const s = await states(t.page);
check(
"horizontal swipe over a scroller opens Detail",
s === "root:parked detail:active",
s,
);
const l = await log(t.page);
check(
"onCommit runs inside the real pointerup event",
l.includes("open committed inPointerup=true"),
l.join(" | "),
);
await t.page.close();
}
// 2. Vertical drag on the same host scrolls natively and leaves the gesture alone.
{
const t = await open();
await drag(t, [200, 700], [205, 300]);
const s = await states(t.page);
const top = await t.page.evaluate(() => window.scrollTopOf("#home-list"));
check(
"vertical drag scrolls the list natively",
top > 100,
`scrollTop=${top}`,
);
check(
"vertical drag does not start the horizontal gesture",
s === "root:active",
s,
);
await t.page.close();
}
// 3. Scroll-edge dismiss.
{
const t = await open();
await drag(t, [340, 400], [60, 405]);
check(
"(setup) Detail open",
(await states(t.page)) === "root:parked detail:active",
);
await drag(t, [200, 700], [200, 300]); // scroll the list down
const scrolled = await t.page.evaluate(() =>
window.scrollTopOf("#detail-list"),
);
check(
"drag up scrolls the detail list",
scrolled > 100,
`scrollTop=${scrolled}`,
);
await drag(t, [200, 300], [200, 600]); // scrolled: drag down scrolls back, no dismiss
const afterDown = await t.page.evaluate(() =>
window.scrollTopOf("#detail-list"),
);
check(
"drag down while scrolled scrolls the list, not dismiss",
afterDown < scrolled &&
(await states(t.page)) === "root:parked detail:active",
`scrollTop ${scrolled} → ${afterDown}`,
);
await t.page.evaluate(
() => (document.querySelector("#detail-list").scrollTop = 0),
);
await drag(t, [200, 300], [200, 800]); // at top: drag down dismisses
const s = await states(t.page);
const l = await log(t.page);
check(
"drag down at the top dismisses",
s === "root:active" && l.includes("dismiss committed"),
s,
);
await t.page.close();
}
// 3b. At the top, an upward drag still scrolls the list.
{
const t = await open();
await drag(t, [340, 400], [60, 405]);
await drag(t, [200, 700], [200, 300]);
const top = await t.page.evaluate(() => window.scrollTopOf("#detail-list"));
check(
"at the top, drag up still scrolls (dismiss lets go)",
top > 100 && (await states(t.page)) === "root:parked detail:active",
`scrollTop=${top}`,
);
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.
for (const [name, expectWarning] of [
["matched", false],
["mismatched", true],
]) {
const t = await open("?view=plain");
await t.page.evaluate((n) => window.begin(n), name);
await t.page.waitForTimeout(100);
const warned = (await log(t.page)).some((l) => l.includes("moved node"));
check(
`continuity warning for a ${name} open/close pair: ${expectWarning ? "warns" : "silent"}`,
warned === expectWarning,
(await log(t.page)).join(" | "),
);
await t.page.close();
}
await browser.close();
server.close();
const failed = results.filter((r) => !r.ok).length;
console.log(`\n${results.length - failed}/${results.length} passed`);
process.exit(failed ? 1 : 0);
-381
View File
@@ -1,381 +0,0 @@
/**
* Differential test for gesture recognition: the baseline and rewritten
* packages each render a scene whose views install equivalent gestures
* (builder, legacy options, OriginGesture, OriginGestureSurface). Identical
* random pointer streams must yield identical scenes and identical callbacks.
*/
import { createApp, defineComponent, h, nextTick, type App } from "vue";
import { afterEach, describe, expect, it } from "vitest";
import * as next from "../src";
import * as base from "./.baseline";
import { flush, installFrames, rng, snapshot } from "./harness";
type Impl = typeof next;
const impls = [base as unknown as Impl, next] as const;
const apps: App[] = [];
afterEach(() => {
for (const app of apps.splice(0)) app.unmount();
document.body.innerHTML = "";
});
const WIDTH = 300;
const HEIGHT = 600;
function stubBounds(element: HTMLElement) {
if ((element as { stubbed?: boolean }).stubbed) return;
(element as { stubbed?: boolean }).stubbed = true;
Object.defineProperties(element, {
clientWidth: { configurable: true, value: WIDTH },
clientHeight: { configurable: true, value: HEIGHT },
});
element.getBoundingClientRect = () =>
({
top: 0,
left: 0,
right: WIDTH,
bottom: HEIGHT,
width: WIDTH,
height: HEIGHT,
x: 0,
y: 0,
toJSON: () => ({}),
}) as DOMRect;
}
function pointOf(point: next.OriginGesturePoint) {
return [point.clientX, point.clientY, point.localX, point.localY];
}
/** Build one implementation's views, gestures, and callback recorder. */
function buildWorld(M: Impl, seed: number) {
const record: unknown[] = [];
const r = rng(seed);
const tick = async (count: number) => {
for (let i = 0; i < count; i += 1) await Promise.resolve();
};
const views: Record<string, next.OriginView> = {};
const slide = M.defineOriginChoreography({
name: "slide",
effects: ({ progress }) => ({
source: { transform: `translateX(${-progress * 100}px)` },
target: { transform: `translateX(${(1 - progress) * 100}px)` },
}),
});
const drawer = M.defineOriginChoreography({
name: "drawer",
persistAtRest: true,
effects: ({ progress }) => ({
source: { transform: `translateY(${progress * 50}%)` },
target: { opacity: progress },
}),
});
// Asynchronous navigation delays are fixed per world so both match.
const delays = Array.from({ length: 64 }, () => r.int(4));
let delayIndex = 0;
const delay = () => tick(delays[delayIndex++ % delays.length]!);
const surfaceGestures = [
M.gesture.to
.left()
.navigate(async (context) => {
record.push(["left", context.nodeKey]);
await delay();
return M.forward(views.B!);
})
.animate(slide),
M.gesture.from
.left(40)
.to.right()
.navigate((context) => {
record.push(["right", context.nodeKey, context.canGoBack]);
return context.canGoBack ? M.back() : null;
})
.animate(M.slideRight),
M.gesture.from
.when(({ point, bounds, origin }) => {
record.push(["when", origin.nodeKey, pointOf(point), bounds.width]);
return point.localY < HEIGHT / 2;
})
.to.down({ threshold: 12, axisDominance: 1.5 })
.complete((context) => {
record.push([
"complete",
context.origin.nodeKey,
context.direction,
context.progress,
context.velocity,
context.distance,
context.crossDistance,
context.duration,
pointOf(context.start),
pointOf(context.current),
context.bounds,
]);
return context.progress > 0.2;
})
.navigate(() => M.forward(views.C!, { placement: "under" }))
.animate(drawer),
M.gesture.from
.bottom(120)
.to.up()
.navigate(async (context) => {
await delay();
if (delays[delayIndex % delays.length] === 0) throw new Error("nav");
return context.canGoBack ? M.back() : M.replace(views.A!);
})
.animate(M.fade),
];
const A = defineComponent({
name: "A",
setup: () => () =>
h(
M.OriginGestureSurface,
{ gestures: surfaceGestures, "data-gesture-host": "" },
() => [h("input", { "data-input": "" }), "A"],
),
});
const bGestures = [
M.gesture.to
.left({ threshold: 6 })
.navigate((context) => {
record.push(["b-left", context.nodeKey, context.history.length]);
return M.forward(views.C!);
})
.animate(slide),
M.gesture.to
.right({ threshold: 6 })
.navigate((context) => (context.canGoBack ? M.back() : null))
.animate(M.slideRight),
];
const B = defineComponent({
name: "B",
setup() {
// Bind each recognizer directly instead of through a component.
const bindings = bGestures.map((definition) =>
M.useOriginGesture(definition),
);
const each =
(name: "onPointerdown" | "onPointermove" | "onPointerup") =>
(event: PointerEvent) =>
bindings.forEach((binding) => binding[name](event));
return () =>
h(
"div",
{
"data-gesture-host": "",
style: bindings[0]!.style,
onPointerdown: each("onPointerdown"),
onPointermove: each("onPointermove"),
onPointerup: each("onPointerup"),
onPointercancel: () =>
bindings.forEach((binding) => binding.onPointercancel()),
},
[h("input", { "data-input": "" }), "B"],
);
},
});
const C = defineComponent({
name: "C",
setup: () => () =>
h(
M.OriginGesture,
{
"data-gesture-host": "",
direction: "up",
edge: 200,
threshold: 4,
action: (context: next.OriginContext) => {
record.push(["legacy", context.nodeKey]);
return context.canGoBack
? M.back(slide)
: M.forward(views.B!, slide);
},
},
() => [h("input", { "data-input": "" }), "C"],
),
});
views.A = M.originView(A, undefined, { key: "a" });
views.B = M.originView(B, undefined, { key: "b" });
views.C = M.originView(C, undefined, { key: "c" });
const scene = M.createOriginScene({ initial: views.A });
const root = document.createElement("div");
document.body.append(root);
const app = createApp({ render: () => h(M.OriginScene, { scene }) });
apps.push(app);
app.mount(root);
return { scene, root, record };
}
function hostFor(root: HTMLElement, nodeKey: string) {
const host = root.querySelector(
`[data-origin-node="${CSS.escape(nodeKey)}"] [data-gesture-host]`,
) as HTMLElement | null;
if (host) stubBounds(host);
return host;
}
function pointerEvent(
type: string,
init: PointerEventInit,
timeStamp: number,
): PointerEvent {
const event = new PointerEvent(type, {
bubbles: true,
cancelable: true,
...init,
});
Object.defineProperty(event, "timeStamp", { value: timeStamp });
return event;
}
const stats = {
maxNodes: 0,
maxOps: 0,
opSteps: 0,
records: {} as Record<string, number>,
finalNodes: {} as Record<number, number>,
};
async function runSequence(seed: number, steps: number) {
const r = rng(seed * 7919);
const frames = installFrames();
const worlds = impls.map((M) => buildWorld(M, seed));
await nextTick();
let time = 100;
let x = 0;
let y = 0;
let pointerId = 1;
let nodeKey = "";
let heading: next.OriginGestureDirection = "left";
let open = false;
const log: string[] = [];
const check = (label: string) => {
const message = `${label}\n${log.join("\n")}`;
expect(snapshot(worlds[1]!.scene), message).toEqual(
snapshot(worlds[0]!.scene),
);
expect(worlds[1]!.record, message).toEqual(worlds[0]!.record);
};
const dispatch = (type: string, init: PointerEventInit, onInput = false) => {
for (const world of worlds) {
const host = hostFor(world.root, nodeKey);
if (!host) continue;
const target = onInput
? (host.querySelector("[data-input]") as HTMLElement)
: host;
target.dispatchEvent(pointerEvent(type, init, time));
}
};
for (let step = 0; step < steps; step += 1) {
const roll = r.next();
time += r.int(40);
if (roll < 0.15) {
if (open && r.chance(0.8)) {
log.push(`up ${x},${y} t=${time}`);
dispatch("pointerup", { clientX: x, clientY: y, pointerId });
await flush(3);
}
open = true;
const nodes = worlds[0]!.scene.nodes.value;
const scene = worlds[0]!.scene;
const interactive = nodes.filter((node) =>
scene.isNodeInteractive(node.key),
);
nodeKey = (
interactive.length && r.chance(0.85)
? r.pick(interactive)
: r.pick(nodes)
).key;
x = r.chance(0.3) ? r.int(50) : r.int(WIDTH);
y = r.chance(0.3) ? HEIGHT - r.int(130) : r.int(HEIGHT);
pointerId = r.chance(0.9) ? 1 : 2;
const init = {
clientX: x,
clientY: y,
pointerId,
button: r.chance(0.95) ? 0 : 1,
isPrimary: r.chance(0.95),
};
heading = r.pick(["left", "right", "up", "down"] as const);
log.push(`down ${nodeKey} ${JSON.stringify(init)} t=${time}`);
dispatch("pointerdown", init, r.chance(0.08));
} else if (roll < 0.65) {
const jitter = r.int(9) - 4;
if (r.chance(0.75)) {
const amount = r.chance(0.1) ? 0 : 5 + r.int(55);
if (heading === "left") x -= amount;
if (heading === "right") x += amount;
if (heading === "up") y -= amount;
if (heading === "down") y += amount;
if (heading === "left" || heading === "right") y += jitter;
else x += jitter;
} else {
x += r.int(70) - 35;
y += r.int(70) - 35;
}
log.push(`move ${x},${y} t=${time}`);
dispatch("pointermove", { clientX: x, clientY: y, pointerId });
} else if (roll < 0.85) {
open = false;
log.push(`up ${x},${y} t=${time}`);
dispatch("pointerup", { clientX: x, clientY: y, pointerId });
} else if (roll < 0.9) {
open = false;
log.push(`cancel`);
dispatch("pointercancel", { pointerId });
} else {
const count = 1 + r.int(20);
log.push(`frames ${count}`);
for (let i = 0; i < count; i += 1) frames.advance(16);
}
if (r.chance(0.7)) {
frames.advance(16);
frames.advance(16);
}
if (!r.chance(0.2)) await flush(3);
check(`seed ${seed} step ${step}`);
stats.maxNodes = Math.max(
stats.maxNodes,
worlds[0]!.scene.nodes.value.length,
);
stats.maxOps = Math.max(
stats.maxOps,
worlds[0]!.scene.operations.value.length,
);
stats.opSteps += worlds[0]!.scene.operations.value.length ? 1 : 0;
}
for (const entry of worlds[0]!.record as unknown[][])
stats.records[entry[0] as string] =
(stats.records[entry[0] as string] ?? 0) + 1;
stats.finalNodes[worlds[0]!.scene.nodes.value.length] =
(stats.finalNodes[worlds[0]!.scene.nodes.value.length] ?? 0) + 1;
for (let i = 0; i < 400 && frames.pending(); i += 1) frames.advance(16);
await flush();
check(`seed ${seed} drained`);
return worlds[0]!.scene.nodes.value.length;
}
describe("gesture: baseline vs rewrite", () => {
const seeds = Number(process.env.SEEDS ?? 200);
for (let seed = 1; seed <= seeds; seed += 1) {
it(`random pointer stream ${seed}`, async () => {
await runSequence(seed, 120);
});
}
it("reached multi-node scenes", () => {
console.log("STATS", JSON.stringify(stats));
expect(stats.maxNodes).toBeGreaterThan(2);
});
});
-105
View File
@@ -1,110 +1,5 @@
import { defineComponent, h, type Component } from "vue";
import type { OriginScene } from "../src";
export function rng(seed: number) {
let a = seed >>> 0;
const next = () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
return {
next,
int: (n: number) => Math.floor(next() * n),
pick: <T>(items: readonly T[]) => items[Math.floor(next() * items.length)]!,
chance: (p: number) => next() < p,
};
}
export function component(name: string): Component {
return defineComponent({ name, render: () => h("div", name) });
}
/** Deterministic rAF + clock shared by both implementations. */
export function installFrames() {
let now = 1000;
let queue: FrameRequestCallback[] = [];
window.requestAnimationFrame = ((cb: FrameRequestCallback) => {
queue.push(cb);
return queue.length;
}) as typeof window.requestAnimationFrame;
globalThis.requestAnimationFrame = window.requestAnimationFrame;
const perf = performance;
perf.now = () => now;
return {
advance(ms = 16) {
now += ms;
const run = queue;
queue = [];
for (const cb of run) cb(now);
},
pending: () => queue.length,
};
}
export async function flush(rounds = 6) {
for (let i = 0; i < rounds; i += 1)
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
/** Implementation-agnostic, comparable snapshot of a scene. */
export function snapshot(scene: OriginScene) {
const nodes = scene.nodes.value;
return {
nodes: nodes.map((node) => ({
key: node.key,
sequence: node.sequence,
view: node.view.key,
previousNodeKey: node.previousNodeKey,
state: node.state,
incomingOperationId: node.incomingOperationId,
history: node.history.map((view) => view.key),
})),
operations: scene.operations.value.map((operation) => ({
id: operation.id,
originKey: operation.originKey,
targetKey: operation.targetKey,
entryTargetKey: operation.entryTargetKey,
choreography: operation.choreography.name,
placement: operation.placement,
history: operation.history,
progress: operation.progress,
velocity: operation.velocity,
phase: operation.phase,
intent: operation.intent,
})),
roots: [...scene.roots.value],
styles: Object.fromEntries(
nodes.map((node) => [node.key, scene.styleForNode(node.key)]),
),
interactive: Object.fromEntries(
nodes.map((node) => [node.key, scene.isNodeInteractive(node.key)]),
),
contexts: Object.fromEntries(
nodes.map((node) => {
const context = scene.contextFor(node.key);
return [
node.key,
{
nodeKey: context.nodeKey,
view: context.view.key,
canGoBack: context.canGoBack,
previous: context.previous?.key,
history: context.history.map((view) => view.key),
},
];
}),
),
missingStyle: scene.styleForNode("missing::0"),
missingInteractive: scene.isNodeInteractive("missing::0"),
};
}
export async function settle<T>(promise: Promise<T>) {
return promise.then(
(value) => ({ ok: true as const, value }),
(error: Error) => ({ ok: false as const, error: error.message }),
);
}
-285
View File
@@ -1,285 +0,0 @@
/**
* Differential test: drives the baseline (main) and rewritten scenes with the
* same random operation sequences and requires identical observable state
* after every step.
*/
import { afterAll, describe, expect, it } from "vitest";
import * as next from "../src";
import * as base from "./.baseline";
import {
component,
flush,
installFrames,
rng,
settle,
snapshot,
} from "./harness";
type Impl = typeof next;
const impls = [base as unknown as Impl, next] as const;
const css = (style: Record<string, string>) => style as never;
const choreographies = [
next.defineOriginChoreography({
name: "layered",
effects: ({ progress }) => ({
source: { transform: `translateX(${-progress * 100}px)` },
target: { transform: `translateX(${(1 - progress) * 100}px)` },
}),
}),
next.defineOriginChoreography({
name: "probe",
commitThreshold: 0.5,
effects: (c) => ({
frame: { style: css({ filter: `blur(${c.progress}px)` }), layer: 1 },
source: {
opacity: 1 - c.progress,
style: css({
opacity: "0.9",
transform: `rotate(${c.velocity}deg)`,
"--state": `${c.phase}/${c.intent}`,
}),
},
target: {
transform: c.progress > 0.5 ? "none" : `scale(${c.progress})`,
layer: 2,
style: css({
borderRadius: `${c.viewport.width}x${c.viewport.height}`,
"--rects": JSON.stringify([
c.originRect ?? null,
c.targetRect ?? null,
]),
position: "fixed",
}),
},
}),
}),
next.defineOriginChoreography({
name: "drawer-open",
persistAtRest: true,
effects: ({ progress }) => ({
source: { transform: `translateX(${progress * 66.6667}%)` },
target: { transform: `translateX(${(progress - 1) * 66.6667}%)` },
}),
}),
next.defineOriginChoreography({
name: "drawer-close",
commitThreshold: 0.2,
effects: ({ progress }) => ({
source: { transform: `translateX(${-progress * 66.6667}%)` },
target: { transform: `translateX(${(1 - progress) * 66.6667}%)` },
}),
}),
next.slideLeft,
next.slideRight,
next.fade,
];
const views = ["A", "B", "C", "D", "E"].map((name) =>
next.originView(component(name), undefined, { key: name.toLowerCase() }),
);
function randomAction(r: ReturnType<typeof rng>): next.OriginAction {
const choreography = r.pick(choreographies);
const target = r.pick(views);
const placement = r.pick(["above", "under", undefined] as const);
switch (r.int(6)) {
case 0:
return next.forward(target, choreography, { placement });
case 1:
return next.replace(target, choreography, { placement });
case 2:
case 3:
return next.back(choreography, { placement });
case 4:
return next.above(target, choreography, {
history: r.pick(["push", "replace", "back", undefined] as const),
});
default:
return next.under(target, choreography, {
history: r.pick(["push", "back"] as const),
});
}
}
type Pair<T> = [T, T];
export const stats = {
undecidedFinishes: 0,
boundary: 0,
begins: 0,
beginOk: 0,
};
interface Slot {
done: [boolean, boolean];
value: [unknown, unknown];
}
async function runSequence(seed: number, steps: number) {
const r = rng(seed);
const frames = installFrames();
const scenes = impls.map((impl) =>
impl.createOriginScene({
initial: seed % 3 === 0 ? [views[0]!, views[1]!] : views[0]!,
}),
) as Pair<next.OriginScene>;
const handles: Pair<next.OriginOperationHandle>[] = [];
const slots: Slot[] = [];
const log: string[] = [];
const track = (promises: Pair<Promise<unknown>>) => {
const slot: Slot = { done: [false, false], value: [undefined, undefined] };
promises.forEach(
(promise, index) =>
void settle(promise).then((value) => {
slot.done[index] = true;
slot.value[index] = value;
}),
);
slots.push(slot);
return slot;
};
const check = (label: string) => {
expect(snapshot(scenes[1]), `${label}\n${log.join("\n")}`).toEqual(
snapshot(scenes[0]),
);
for (const slot of slots) {
expect(slot.done[1], label).toBe(slot.done[0]);
expect(slot.value[1], label).toEqual(slot.value[0]);
}
};
const pickHandle = () => {
const liveIds = new Set(scenes[0].operations.value.map((o) => o.id));
const live = handles.filter(([handle]) => liveIds.has(handle.id));
return live.length && r.chance(0.85) ? r.pick(live) : r.pick(handles);
};
const boundaryUpdate = (pair: Pair<next.OriginOperationHandle>) => {
// Bias toward the commit-decision boundaries.
const progress = r.chance(0.4)
? r.pick([0, 0.2, 0.35999, 0.36, 0.49999, 0.5, 1])
: r.next() * 1.4 - 0.2;
const velocity = r.chance(0.2)
? undefined
: r.chance(0.4)
? r.pick([-0.3, 0, 0.29999, 0.3, 0.30001, 0.31])
: r.next() * 4 - 2;
log.push(`update ${pair[0].id} ${progress} ${velocity}`);
pair.forEach((handle) => handle.update(progress, velocity));
};
for (let step = 0; step < steps; step += 1) {
const roll = r.next();
const nodeKeys = scenes[0].nodes.value.map((node) => node.key);
if (roll < 0.3 || handles.length === 0) {
const originKey = r.chance(0.03) ? "missing::0" : r.pick(nodeKeys);
const action = randomAction(r);
log.push(
`begin ${originKey} ${action.history} ${action.choreography.name}`,
);
const slot = track(
scenes.map((scene) => scene.begin(originKey, action)) as Pair<
Promise<next.OriginOperationHandle>
>,
);
await flush();
if (slot.done[0] && slot.done[1]) {
const [a, b] = slot.value as Pair<
{ ok: true; value: next.OriginOperationHandle } | { ok: false }
>;
stats.begins += 1;
if (a.ok && b.ok) {
stats.beginOk += 1;
handles.push([a.value, b.value]);
// Handles are compared by identity fields only.
slot.value = [
{ id: a.value.id, o: a.value.originKey, t: a.value.targetKey },
{ id: b.value.id, o: b.value.originKey, t: b.value.targetKey },
];
}
}
} else if (roll < 0.55) {
boundaryUpdate(pickHandle());
} else if (roll < 0.75) {
const pair = pickHandle();
if (r.chance(0.5)) boundaryUpdate(pair);
const options = {
commit: r.pick([true, false, undefined]),
animate: r.chance(0.5),
};
log.push(`finish ${pair[0].id} ${JSON.stringify(options)}`);
const live = scenes[0].operations.value.find((o) => o.id === pair[0].id);
if (live && options.commit === undefined) {
stats.undecidedFinishes += 1;
if (live.velocity >= 0.3 && live.velocity < 0.31) stats.boundary += 1;
}
track(
pair.map((handle) => handle.finish(options)) as Pair<Promise<boolean>>,
);
} else if (roll < 0.82) {
const pair = pickHandle();
const animate = r.pick([true, false, undefined]);
log.push(`cancel ${pair[0].id} ${animate}`);
track(
pair.map((handle) => handle.cancel({ animate })) as Pair<Promise<void>>,
);
} else {
const count = 1 + r.int(12);
log.push(`frames ${count}`);
for (let i = 0; i < count; i += 1) frames.advance(4 + r.int(40));
}
if (!r.chance(0.15)) await flush();
check(`seed ${seed} step ${step}`);
}
// Drain every running spring and compare the resting state.
for (let i = 0; i < 400 && frames.pending(); i += 1) frames.advance(16);
await flush();
check(`seed ${seed} drained`);
}
describe("scene: baseline vs rewrite", () => {
it("produces identical action and intent objects", () => {
const target = views[1]!;
const c = choreographies[0]!;
const calls: Array<(m: Impl) => unknown> = [
(m) => m.originAction(target, c),
(m) => m.originAction(target, c, { placement: "under", history: "back" }),
(m) => m.forward(target),
(m) => m.forward(target, { placement: "under" }),
(m) => m.forward(target, c),
(m) => m.forward(target, c, { placement: "under" }),
(m) => m.replace(target),
(m) => m.replace(target, c, {}),
(m) => m.back(),
(m) => m.back({ placement: "above" }),
(m) => m.back(c),
(m) => m.back(c, { placement: "above" }),
(m) => m.above(target),
(m) => m.above(target, { history: "replace" }),
(m) => m.above(target, c),
(m) => m.above(target, c, { history: "back" }),
(m) => m.under(target),
(m) => m.under(target, c, { history: "back" }),
(m) => m.normalizedEffect(undefined),
(m) => m.normalizedEffect({ transform: "x", layer: 2 }, -1),
];
for (const call of calls) {
const [a, b] = impls.map(call);
expect(b).toStrictEqual(a);
expect(Object.keys(b as object)).toEqual(Object.keys(a as object));
}
});
afterAll(() => console.log("STATS", JSON.stringify(stats)));
it("exports the same runtime names", () => {
expect(Object.keys(next).sort()).toEqual(Object.keys(base).sort());
});
const seeds = Number(process.env.SEEDS ?? 300);
for (let seed = 1; seed <= seeds; seed += 1) {
it(`random sequence ${seed}`, () => runSequence(seed, 60));
}
});
+1 -2
View File
@@ -2,7 +2,7 @@ import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vitest/config";
/*
* Differential verification against a previous core-v2 implementation.
* Benchmarks against a previous core-v2 implementation.
* Run `setup-baseline.sh <git-ref>` first; see README.md.
*/
export default defineConfig({
@@ -10,7 +10,6 @@ export default defineConfig({
test: {
root: __dirname,
environment: "happy-dom",
include: ["*.diff.ts"],
testTimeout: 120_000,
benchmark: { include: ["*.bench.ts"] },
},
+2 -1
View File
@@ -24,6 +24,7 @@
"apps/demo/src/**/*.ts",
"apps/demo/src/**/*.vue",
"packages/*/src/**/*.ts",
"packages/*/src/**/*.vue"
"packages/*/src/**/*.vue",
"packages/*/tests/**/*.ts"
]
}