Files
Native-Router-Vue/packages/core-v2/API.md
T
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

1074 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Core v2 API reference
`@native-vue-router/core-v2` is a routeless Vue scene compositor. A mounted
component can create another component, animate both through a local operation
frame, and retain either side when the operation resolves.
This document describes the experimental `0.1.0-experimental.0` API.
## Contents
- [Gesture start recognition](#gesture-start-recognition)
- [Minimum setup](#minimum-setup)
- [Components](#components)
- [Nested scenes](#nested-scenes)
- [Scene and view functions](#scene-and-view-functions)
- [Actions and history](#actions-and-history)
- [Gestures](#gestures)
- [Choreographies and effects](#choreographies-and-effects)
- [Node-scoped controls](#node-scoped-controls)
- [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
The builder separates where a gesture begins from the direction it moves:
```ts
const edgeBack = gesture.from
.left("clamp(24px, 7vw, 48px)")
.to.right()
.navigate((context) => (context.canGoBack ? back() : null))
.animate(slideRight);
```
Edges are measured from the **gesture host element**, not unconditionally from
the browser viewport. Supported start rules are:
```ts
gesture.from.left(distance);
gesture.from.right(distance);
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
percentages, `calc()`, and `clamp()`. It is resolved against the current host
size at pointer-down.
`.from` is optional. A chain beginning at `.to` admits pointer-down anywhere:
```ts
gesture.to.right();
```
This is semantically equivalent to:
```ts
gesture.from.anywhere().to.right();
```
It does not capture on the first positive pixel. The recognizer waits until
directed movement crosses the intent threshold and dominates the cross-axis.
Custom shapes, safe-area rules, and exclusion zones belong in `.from.when()`:
```ts
const dropDialog = gesture.from
.when(({ point, bounds, event }) => {
const rail = Math.max(36, bounds.width * 0.08);
const outsideExcludedBand =
event.clientY < bounds.top + bounds.height * 0.35 ||
event.clientY > bounds.top + bounds.height * 0.65;
return point.localX <= rail && outsideExcludedBand;
})
.to.down()
.navigate(() => above(originView(DialogView)))
.animate(dropAnimation);
```
`.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, such as a horizontally scrolling chip row inside a host with a
horizontal gesture.
## Minimum setup
Import the required compositor stylesheet once:
```ts
import "@native-vue-router/core-v2/style.css";
```
Create a scene:
```ts
import { createOriginScene, originView } from "@native-vue-router/core-v2";
import HomeView from "./HomeView.vue";
export const scene = createOriginScene({
initial: originView(HomeView, undefined, {
key: "home",
name: "Home",
}),
});
```
Render it:
```vue
<script setup lang="ts">
import { OriginScene } from "@native-vue-router/core-v2";
import { scene } from "./scene";
</script>
<template>
<OriginScene :scene="scene" />
</template>
```
`OriginScene` must have a non-zero width and height through its parent layout.
## Components
### `OriginScene`
Renders every currently mounted scene node as a stable, absolutely positioned
sibling.
| Prop | Type | Required | Description |
| ------- | ------------- | -------- | -------------------------------------- |
| `scene` | `OriginScene` | yes | Scene created by `createOriginScene()` |
The component provides node ownership to descendants, registers host elements
for measurement, and applies the scene's composited styles. Application views
must be rendered through this component before calling `useOrigin()` or
`useOriginGesture()`.
### `OriginGesture`
Convenience component that renders one HTML element and binds one gesture
recognizer to it.
| Prop | Type | Default | Description |
| --------- | ------------------------- | ------- | ---------------------------------- |
| `as` | `string` | `"div"` | HTML tag used for the gesture host |
| `gesture` | `OriginGestureDefinition` | — | Builder definition |
Attributes, classes, and listeners not consumed as props are forwarded to the
rendered host.
```vue
<OriginGesture as="main" class="profile" :gesture="openDetailsGesture">
...
</OriginGesture>
```
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.
### `OriginGestureSurface`
Policy-neutral host for multiple completed builder definitions:
```vue
<script setup lang="ts">
const gestures = [forwardGesture, backGesture] as const;
</script>
<template>
<OriginGestureSurface as="main" :gestures="gestures">
...
</OriginGestureSurface>
</template>
```
| Prop | Type | Default | Description |
| ---------- | ------------------------------------ | -------- | ------------------------------ |
| `as` | `string` | `"div"` | Shared native gesture host |
| `gestures` | `readonly OriginGestureDefinition[]` | required | Fully defined page-owned rules |
The component adds no recognition or navigation policy. It installs each
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).
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
`OriginScene` is a reusable compositor, not an application-only singleton. A
component may render another scene inside its own layout:
```vue
<section class="carousel">
<OriginScene :scene="carouselScene" />
</section>
```
The child scene gets independent mounted nodes, retained history, measurements,
operations, and clipping. `useOrigin()` and `useOriginGesture()` resolve the
nearest scene-node provider, so declarations inside a carousel slide operate
on carousel components rather than the outer page.
Parent/child gesture arbitration is currently pointer-down based. An eligible
child recognizer stops propagation immediately. If its later `.navigate()`
factory returns `null`, that same pointer sequence is not offered to the
parent. Cooperative nested components should reserve a start region with
`.from.when()` or `.from.left()` that allows the parent handler to receive
pointer-down.
The nested-scenes demo contains both this cooperative policy and an intentional
greedy-child conflict. A future gesture arena could delay ownership until
direction and navigation availability are known.
## Scene and view functions
### `originView(component, props?, options?)`
Creates a lightweight recipe for mounting a Vue component.
```ts
const profile = originView(
ProfileView,
{ userId: "42" },
{ key: "profile-42", name: "Profile" },
);
```
The recipe is not itself a mounted instance. A forward action creates an
instance from it, then retains that exact instance while its entry remains in
history. Back does not call the recipe again. Component definitions are marked
raw so Vue does not proxy them inside reactive scene structures.
`OriginViewOptions`:
| Field | Type | Description |
| ------ | -------- | --------------------------------------------- |
| `key` | `string` | Recipe identity and generated node-key prefix |
| `name` | `string` | Human-readable diagnostic label |
When no key is supplied, one is generated from the component/name and a
sequence number.
### `createOriginScene(options)`
Creates one independent scene graph, history context, and compositor.
```ts
const scene = createOriginScene({
initial: originView(HomeView),
});
```
`options.initial` accepts one `OriginView` or an array of independent root
views. Scenes do not share nodes, history, operation IDs, or measurements.
## Actions and history
### `forward(target, choreography?, options?)`
With choreography, creates a complete retained-history push action:
```ts
const openProfile = () =>
forward(originView(ProfileView, { userId: "42" }), slideLeft);
```
Without choreography, it creates an `OriginNavigationIntent` for a gesture
builder:
```ts
.navigate(() => forward(originView(ProfileView, { userId: "42" })))
```
When forward commits, the origin remains mounted but becomes parked. Its DOM,
component-local state, and nested scroll positions remain intact.
`OriginNavigationActionOptions.placement` defaults to `"above"`.
### `back(choreography?, options?)`
Creates a retained-history pop action:
```ts
const goBack = (context: OriginContext) =>
context.canGoBack ? back(slideRight) : null;
```
Back has no target recipe. When it begins, the scene resolves the origin's
`previousNodeKey` and reveals that exact mounted instance. A committed back
unmounts only the current entry. A cancelled back hides the previous entry
again and leaves the current entry active.
`back()` without choreography returns an animation-free navigation intent for
`.navigate()`. `back(slideRight)` returns a complete programmatic action.
`OriginNavigationActionOptions.placement` defaults to `"under"`.
### `replace(target, choreography?, options?)`
Creates a new target while removing the current history entry:
```ts
const confirmOrder = () =>
replace(
originView(OrderConfirmationView, { orderId: "NVO-2048" }),
slideLeft,
);
```
The target inherits the origin's `previousNodeKey`, so a later back skips the
replaced entry. The origin remains mounted while the operation is interactive
or settling and is unmounted only after commit. Cancelling removes the proposed
target and restores the origin without changing history.
Without choreography, `replace(target)` creates an intent suitable for a
gesture builder:
```ts
.navigate(() => replace(originView(OrderConfirmationView)))
.animate(slideLeft)
```
`OriginNavigationActionOptions.placement` defaults to `"above"`.
### `originAction(target, choreography, options?)`
Constructs a complete low-level `OriginAction`. Prefer `forward()`, `replace()`,
and `back()` when expressing retained navigation.
```ts
const action = originAction(profile, slideLeft, {
placement: "above",
history: "push",
});
```
`OriginActionOptions`:
| Field | Type | Default | Description |
| ----------- | -------------------- | --------- | ---------------------------- |
| `placement` | `"above" \| "under"` | `"above"` | Target stacking relationship |
| `history` | `OriginHistoryMode` | `"push"` | Target history mutation |
### `above(target, choreography?, options?)`
Shorthand for `originAction()` with `placement: "above"`.
```ts
const openProfile = () => above(originView(ProfileView), slideLeft);
```
Placement controls stacking only. It does not imply a movement direction.
Omitting choreography returns a navigation intent for a gesture builder.
### `under(target, choreography?, options?)`
Shorthand for `originAction()` with `placement: "under"`.
```ts
const goBack = (context: OriginContext) =>
context.previous ? back(slideRight) : null;
```
`under()` does not automatically mean history back. It remains available for
custom stacking actions; `back()` is the clearer retained-history primitive.
Omitting choreography returns a navigation intent for a gesture builder.
### History modes
History is a linked chain of mounted scene nodes.
| Mode | Commit behavior |
| ----------- | -------------------------------------------------------------- |
| `"push"` | Park and retain the origin; activate the new target |
| `"replace"` | Create a new target, inherit prior history, unmount the origin |
| `"back"` | Reuse the retained previous target; pop and unmount the origin |
Parked entries are `inert`, `aria-hidden`, invisible, and excluded from pointer
input. They remain mounted until back pops them or the scene is destroyed.
## Gestures
### `gesture`
Immutable fluent builder for component-owned gesture policy:
```ts
const swipeBack = gesture.from
.left(32)
.to.right({ threshold: 10 })
.complete(({ progress, velocity }) => progress >= 0.4 || velocity >= 0.9)
.navigate((context) => (context.canGoBack ? back() : null))
.animate(slideRight);
```
The stages have distinct responsibilities:
| Stage | Responsibility |
| ---------------------- | ---------------------------------------------------------- |
| `.from.*` | Optional pointer-down eligibility |
| `.to.*` | Required movement direction and intent-recognition options |
| `.complete(predicate)` | Optional release commit/cancel decision |
| `.navigate(factory)` | Required target and retained-history intent |
| `.onCommit(handler)` | Optional side effect when a release commits |
| `.animate(routine)` | Required source/target/frame choreography |
The builder is persistent and immutable. Reusing an earlier stage cannot
change a definition already produced from it.
`.to.left()`, `.to.right()`, `.to.up()`, and `.to.down()` accept optional
`OriginGestureDirectionOptions`:
| Field | Default | Description |
| --------------- | ------- | ------------------------------------------------- |
| `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.
The completion context contains the origin, direction, normalized progress and
velocity, directed pixel distance, cross-axis distance, duration, pointer-up
event, host, bounds, and start/current points. Completion predicates are
synchronous because they select operation intent at release.
`.onCommit(handler)` is available at every stage after `.to.*`, returns the
same stage, and may be called more than once; handlers run in order. A handler
runs once, synchronously at release, after the operation commits and before
the settling animation, and receives the same context as `.complete()`. It
never runs for a cancelled release. A throwing handler is reported as an
uncaught error without stopping later handlers or the operation. It is the
place for haptic feedback:
```ts
const openProfile = gesture.to
.left()
.navigate(() => forward(originView(ProfileView)))
.onCommit(() => navigator.vibrate?.(10))
.animate(slideLeft);
```
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. `definition` may also be a ref or getter; a changed definition
applies from the next pointer-down.
```ts
const open = useOriginGesture(
gesture.to
.left({ threshold: 10 })
.navigate(() => forward(originView(DetailsView)))
.animate(slideLeft),
);
```
The return value contains:
```ts
interface OriginGestureBinding {
readonly style: Readonly<CSSProperties>;
readonly onPointerdown: (event: PointerEvent) => void;
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 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 from the pointer-down point reaches `threshold`.
4. Directed movement exceeds cross-axis movement by `axisDominance`.
5. The navigation factory returns an intent.
Recognition is measured from where the pointer went down and keeps watching
until the pointer lifts, so a slightly wobbly or backward start only delays
it.
Progress is directed distance divided by the gesture's `distance` (by default
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.
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
### `defineOriginChoreography(choreography)`
Type-safe identity helper for declaring custom visual routines.
```ts
const scaleIn = defineOriginChoreography({
name: "scale-in",
commitThreshold: 0.4,
commitVelocity: 0.8,
effects: ({ progress, viewport }) => ({
source: {
transform: `scale(${1 - progress * 0.08})`,
opacity: 1 - progress * 0.3,
},
target: {
transform: `translateY(${(1 - progress) * viewport.height}px)`,
},
}),
});
```
The function returns the same object. Its value is type checking and a clear
construction point.
Set `persistAtRest: true` when progress `1` should remain as a connected visual
relationship after a committed push:
```ts
const openPartialDrawer = defineOriginChoreography({
name: "partial-drawer-open",
persistAtRest: true,
effects: ({ progress }) => ({
source: {
transform: `translateX(${progress * 66.6667}%)`,
},
target: {
transform: `translateX(${(progress - 1) * 66.6667}%)`,
},
}),
});
```
This leaves the retained source mounted, visible, and inert instead of parking
it. The target remains the active history entry. Beginning back suspends the
resting relationship so a reciprocal close choreography can take over;
cancelling back restores it exactly.
Connected resting effects are supported only by retained-history push actions.
They are designed for partial drawers, inspectors, and other presentations
where both mounted views remain visible after commit. They do not appear in
`scene.operations`, which reports live interactive/settling edges only.
`effects()` may return:
| Effect | Applied to |
| -------- | ---------------------------------------------------------- |
| `frame` | Source, target, and descendants on both sides of this edge |
| `source` | Component that originated this operation |
| `target` | Component created by this operation and its descendants |
Transforms are concatenated from inherited frames to local frames. Opacity is
multiplied. Properties inside `style` use local-last precedence, except
`transform` and numeric `opacity`, which are also composed.
Choreography callbacks should be deterministic and free of side effects. They
can run repeatedly during rendering and animation.
### Commit thresholds
When `finish()` does not explicitly override the decision, the release velocity
decides first, then distance:
- 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`.
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.
### Included presets
| Export | Behavior |
| ------------ | ------------------------------------------------------------- |
| `slideLeft` | Target enters from the right above a slightly receding source |
| `slideRight` | Source exits right and reveals a target underneath |
| `fade` | Source fades out as target fades in |
These are ordinary `OriginChoreography` objects and can be replaced entirely.
### `normalizedEffect(effect, fallbackLayer?)`
Internal compositor helper exposed for custom diagnostics or compositors. It
returns a defined effect and adds `fallbackLayer` to the effect's own layer.
Applications normally return plain effects and let the scene normalize them.
## Node-scoped controls
### `useOrigin()`
Returns controls scoped to the scene node containing the calling component.
```ts
const origin = useOrigin();
await origin.perform(forward(originView(SettingsView), fade));
```
Return value:
| Field | Description |
| ----------------- | --------------------------------------------------- |
| `nodeKey` | Unique key of this mounted node |
| `scene` | Containing `OriginScene` |
| `context` | Reactive node-local `OriginContext` |
| `view` | Reactive shorthand for the current recipe |
| `previous` | Recipe belonging to the retained previous instance |
| `canGoBack` | Whether a retained previous instance exists |
| `begin(action)` | Create a target and return manual operation control |
| `perform(action)` | Create and programmatically commit a target |
The composable throws when called outside a view mounted by `OriginScene`.
There is no global `activeView`; the injected node containing the event is the
origin.
## Scene diagnostics and manual operations
### `OriginScene` fields
| Field | Type | Description |
| ------------ | ----------------------------------------- | ------------------------------------- |
| `nodes` | `ComputedRef<readonly OriginSceneNode[]>` | Currently mounted Vue component nodes |
| `operations` | `ComputedRef<readonly OriginOperation[]>` | Live operation edges |
| `roots` | `ShallowRef<readonly string[]>` | Visible operation-graph roots |
These fields are suitable for inspectors and diagnostics. Do not mutate their
contents.
### `scene.contextFor(nodeKey)`
Returns the `OriginContext` for a mounted node. Throws if the key no longer
exists.
### `scene.begin(originKey, action)`
For forward or replace, mounts a new target and waits one Vue tick for
measurement. For back, reveals and measures the retained previous node. It
then returns an `OriginOperationHandle`.
```ts
const handle = await scene.begin(nodeKey, action);
handle.update(0.25, 0.4);
const committed = await handle.finish();
```
Only one outgoing operation may exist for a given origin. Its created target
can immediately begin its own outgoing operation, enabling X → Y → Z chains.
### `OriginOperationHandle`
`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 |
| ----------------------------- | --------------------------------------------------- |
| `id` | Unique operation ID |
| `originKey` | Source node key |
| `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`:
| Field | Default | Description |
| --------- | ------------------ | ---------------------------- |
| `commit` | threshold decision | Force commit or cancellation |
| `animate` | `true` | Run the settling spring |
### `scene.perform(originKey, action)`
Equivalent to beginning an operation and immediately finishing it with
`commit: true`. The target still uses the settling spring unless reduced motion
is active.
### Renderer integration methods
`registerElement()`, `registerContainer()`, `styleForNode()`, and
`isNodeInteractive()` are public at the TypeScript boundary because the Vue
renderer components consume them. They are internal integration APIs and may
change during the experimental series.
## Type reference
### `OriginView<Props>`
A component recipe containing `component`, optional `props`, optional `key`,
and optional diagnostic `name`.
### `OriginAction`
A choreography, placement, history mode, and—except for back—target recipe.
### `OriginNavigationIntent`
An animation-free target, placement, and history mutation returned by
`forward()`, `replace()`, `back()`, `above()`, or `under()` when choreography
is omitted. Gesture `.animate()` combines it with choreography to create the
internal action.
### Gesture definition types
- `OriginGestureDefinition`: immutable executable result passed to
`useOriginGesture()` or the `OriginGesture` component.
- `OriginGestureStart`: anywhere, edge, or predicate start policy.
- `OriginGestureDistance`: numeric CSS pixels or a CSS length string.
- `OriginGestureStartContext`: pointer-down event, origin, host, bounds, and
local/client point.
- `OriginGestureCompletionContext`: release metrics and origin/DOM context.
- `OriginGestureDirectionOptions`: `threshold` and `axisDominance`.
- `OriginGestureBinding`: host style and four pointer handlers.
- `OriginGestureSurfaceProps`: shared host tag and completed definition list.
- `MaybeOriginNavigationIntent`: synchronous or asynchronous nullable
navigation-factory result.
### `OriginContext`
Node-local action context:
- `nodeKey`: mounted origin identity.
- `view`: origin recipe.
- `canGoBack`: whether a retained previous instance exists.
- `previous`: recipe belonging to the mounted previous entry.
- `history`: recipes belonging to all retained previous entries.
### `OriginEffect`
| Field | Description |
| ----------- | ----------------------------------------------- |
| `transform` | Composable CSS transform contribution |
| `opacity` | Multiplicative opacity contribution |
| `layer` | Relative stacking contribution |
| `style` | Other CSS properties with local-last precedence |
`above()` adds a default target layer of `+1`; `under()` adds `-1`.
### `OriginChoreographyContext`
| Field | Description |
| ------------ | ------------------------------------------------------ |
| `progress` | Normalized `0..1` progress |
| `velocity` | Normalized progress units per second |
| `phase` | `preparing`, `interactive`, `settling`, or `finished` |
| `intent` | `undecided`, `commit`, or `cancel` |
| `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.
### Diagnostic types
- `OriginSceneNode`: mounted identity, retained previous key, state, recipe,
history, and incoming edge.
- `OriginSceneNodeState`: `active`, `transitioning`, `exposed`, or `parked`.
- `OriginOperation`: read-only live edge state.
- `OriginOperationPhase`: operation lifecycle phase.
- `OriginOperationIntent`: selected operation outcome.
- `OriginRect`: top, left, width, and height.
### Internal types
`OriginNodeScope` and `MutableOriginOperation` are renderer/runtime
implementation types. They are exported by the current barrel but marked
`@internal` and should not be application dependencies.
## 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.
- Parked or exposed retained entries are inert and cannot originate operations.
The active connected target owns interactions until back reveals its source.
- A target can originate its own operation as soon as its incoming operation's
intent becomes commit.
- The included recognizer follows one primary pointer and one axis.
- Builder edges accept CSS lengths; arbitrary start policy belongs in
`.from.when()`.
- Every pushed history entry retains its Vue instance and DOM until a committed
back operation pops it. There is no eviction policy yet.
- Parked instances remain mounted, so their ordinary Vue effects and timers
continue running.
- 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.