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

Core v2: routeless origins

@native-vue-router/core-v2 is an experimental, Vue-only scene compositor. It does not install Vue Router, resolve URLs, select a globally active route, or render through RouterView.

The complete function, component, option, type, gesture-edge, and choreography reference is in API.md.

Install from Git

The repository root exposes this workspace package, runs its build during npm's Git-dependency preparation, and includes the generated JavaScript, declarations, and stylesheet:

npm install git+https://git.harvmaster.com/Harvmaster/Native-Router-Vue.git

Pin applications to a commit or tag when reproducibility matters:

npm install "git+https://git.harvmaster.com/Harvmaster/Native-Router-Vue.git#<commit-or-tag>"

Vue 3.5 or newer is the only peer dependency.

The Git-installed repository package includes a Codex integration skill at skills/build-with-native-vue-router-v2. Copy that folder into ${CODEX_HOME:-$HOME/.codex}/skills and invoke it as $build-with-native-vue-router-v2.

The primitive is:

A mounted component can originate a routine that creates another component, moves both components relative to the origin's coordinate frame, and retains the previous instance until a committed back operation pops the newer entry.

Run the experiment

From the workspace root:

npm run dev:v2

Open the printed URL to explore nine physical labs:

  • a four-view chain that can keep four nodes and three edges live at once;
  • one view with horizontal, vertical, and edge-only declarations;
  • programmatic gallery navigation followed by gesture-owned traversal;
  • a vertically presented media player with local interactive state;
  • a chat that intentionally declares no back gesture;
  • a predicate-gated downward gesture that drops a left-edge dialog;
  • three nested scenes demonstrating cooperative carousels, vertical decks, and an intentional parent/child gesture conflict; and
  • a checkout flow that replaces Payment Details with Confirmation and proves that back returns directly to the retained Hub instance; and
  • a connected two-thirds drawer that keeps the translated source page visible in the exposed final third.

The expandable inspector reports mounted Vue instances, active operation edges, animation progress, and recent lifecycle events. In the chain lab, swipe rapidly through X → Y → Z → Ω to see all four components mounted while their independent frames are still moving.

An installable, offline-capable PWA build of the same experiment is hosted at https://v2.demo.native-router.harvmaster.com/.

Basic usage

Create a scene with a component recipe:

import { createOriginScene, originView } from "@native-vue-router/core-v2";
import "@native-vue-router/core-v2/style.css";
import HomeView from "./HomeView.vue";

export const scene = createOriginScene({
  initial: originView(HomeView, { accountId: "42" }, { key: "home" }),
});

Render it:

<script setup lang="ts">
import { OriginScene } from "@native-vue-router/core-v2";
import { scene } from "./scene";
</script>

<template>
  <OriginScene :scene="scene" />
</template>

Declare an interaction inside the component that should originate it:

<script setup lang="ts">
import {
  OriginGesture,
  forward,
  gesture,
  originView,
  slideLeft,
} from "@native-vue-router/core-v2";
import ProfileView from "./ProfileView.vue";

const openProfile = gesture.to
  .left()
  .navigate(() =>
    forward(originView(ProfileView, { userId: "7" }, { key: "profile-7" })),
  )
  .animate(slideLeft);
</script>

<template>
  <OriginGesture :gesture="openProfile">
    <main>Swipe this component left</main>
  </OriginGesture>
</template>

Starting directly at .to.left() means pointer-down may occur anywhere on the host. Add .from.left("clamp(24px, 8%, 64px)") before .to.right() for a conventional proportional back edge, or .from.when(context => ...) for arbitrary start policy. .complete() can override the choreography's release thresholds.

There is no global navigation declaration. If this component should not support that gesture, it simply does not render OriginGesture.

For an existing element where an additional wrapper is undesirable, use useOriginGesture() and attach its four pointer handlers directly.

For several gestures on one page surface, keep the definitions in the page and pass them to the policy-neutral host:

<script setup lang="ts">
import { OriginGestureSurface } from "@native-vue-router/core-v2";

const gestures = [forwardGesture, backGesture] as const;
</script>

<template>
  <OriginGestureSurface as="main" :gestures="gestures">
    ...
  </OriginGestureSurface>
</template>

Going backward

Every pushed history entry remains mounted. Back resolves the already-mounted previous node and pops only the current entry after the operation commits:

import {
  back,
  gesture,
  slideRight,
  useOriginGesture,
} from "@native-vue-router/core-v2";

const goBack = useOriginGesture(
  gesture.from
    .left("max(24px, 6%)")
    .to.right()
    .navigate((context) => (context.canGoBack ? back() : null))
    .animate(slideRight),
);

Parked entries are visually hidden, inert, and removed from pointer and accessibility interaction. Their Vue instances and DOM remain mounted, so component-local state and nested element scroll positions are preserved naturally. A cancelled back re-parks the previous target; a committed back unmounts the entry being left.

The application chooses whether this is exposed as a left-edge gesture, toolbar button, keyboard shortcut, Android hardware-back action, or not exposed at all.

Replacing the current entry

Use replace() for completed one-way flows such as Payment Details → Confirmation:

import {
  originView,
  replace,
  slideLeft,
  useOrigin,
} from "@native-vue-router/core-v2";
import OrderConfirmationView from "./OrderConfirmationView.vue";

const origin = useOrigin();

function confirmOrder() {
  return origin.perform(
    replace(
      originView(OrderConfirmationView, { orderId: "NVO-2048" }),
      slideLeft,
    ),
  );
}

The replacement inherits the current entry's mounted predecessor. It does not retain the entry being replaced, so back from Confirmation skips Payment Details. The mutation is atomic: a cancelled interactive replacement removes the proposed Confirmation and restores Payment Details unchanged.

Inside a gesture builder, omit choreography from the intent:

gesture.to
  .left()
  .navigate(() => replace(originView(OrderConfirmationView)))
  .animate(slideLeft);

Custom choreography

A choreography returns independent effects for its source, target, and their shared frame:

import { defineOriginChoreography } from "@native-vue-router/core-v2";

export const zoomFromCard = defineOriginChoreography({
  name: "zoom-from-card",
  commitThreshold: 0.42,
  effects: ({ progress, originRect, viewport }) => ({
    source: {
      transform: `scale(${1 - progress * 0.08})`,
      opacity: 1 - progress * 0.4,
    },
    target: {
      transform: `translateY(${(1 - progress) * viewport.height}px)`,
      style: {
        borderRadius: `${(1 - progress) * 24}px`,
      },
    },
  }),
});

originRect, targetRect, and the scene viewport are measured after the target mounts. The gesture may update progress interactively or a normal click can call useOrigin().perform(action).

Transforms are concatenated from the oldest origin frame to the newest local effect. Opacity is multiplied. Other properties in style use local-last precedence. Consequently, if X→Y and Y→Z overlap:

Y transform = (X→Y target) × (Y→Z source)
Z transform = (X→Y target) × (Y→Z target)

For partial presentations that must keep both views visible after commit, set persistAtRest: true on the opening choreography:

const openDrawer = defineOriginChoreography({
  persistAtRest: true,
  effects: ({ progress }) => ({
    source: { transform: `translateX(${progress * 66.6667}%)` },
    target: { transform: `translateX(${(progress - 1) * 66.6667}%)` },
  }),
});

The retained source becomes visible-but-inert rather than parked. A reciprocal back choreography closes the target; a cancelled close restores the connected resting effects and both original Vue instances.

Why scene nodes are flat

The operation graph is not represented as Vue component ancestry. Every component has one stable, keyed host directly under OriginScene.

If Y were physically moved from an X→Y wrapper to the scene root when an edge collapsed, Vue would unmount and recreate Y. Instead, v2 rewrites graph edges and recalculates Y's effect layers while every retained VNode stays in the same flat host.

“Y is Z's origin” is a coordinate and retained-history relationship, not Vue component ancestry.

Source layout

Each module depends only on the ones listed above it.

File Responsibility
shared.ts Geometry and environment helpers
choreography.ts Effect types, defineOriginChoreography(), presets, and composeEffects() style compositing
navigation.ts originView() recipes and forward(), back(), replace(), above(), under() actions
scene/types.ts Public scene types
scene/graph.ts SceneGraph: node and edge storage plus the begin/commit/cancel history rules (all synchronous)
scene/scene.ts Scene and OperationHandle: mounting, measuring, settling spring, compositing, and useOrigin
gesture/definition.ts Gesture types, component props, and the immutable gesture builder
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 consts that the class calls, so they can be tested directly without widening the class's interface.

Current experimental boundaries

  • One operation creates one target. Chaining operations already permits any number of simultaneous scene nodes; multi-target routines are not yet exposed as a public builder.
  • A node can originate one outgoing operation at a time. Its created target may immediately originate the next operation.
  • The included pointer recognizer handles one primary pointer and one axis. Choreographies and scene operations are independent of it.
  • Multiple recognizers can share a host and arbitrate by start policy and directional intent. A dedicated multi-pointer gesture arena is not exposed.
  • Nested OriginScene components have independent history and measurements. Child recognizers currently claim propagation at pointer-down, so yielding a region to a parent requires an explicit .from policy.
  • Every pushed history entry remains mounted until back pops it. There is not yet an eviction policy, so applications should deliberately reset long-lived navigation contexts when that API is introduced.
  • Parked instances remain mounted and ordinary Vue timers/watchers continue to run. Engine-specific park/resume lifecycle hooks are not exposed yet.
  • Arbitrary CSS properties can be used, but only transforms and opacity have defined multi-operation composition rules at present.

These boundaries are explicit so the experiment can validate the origin primitive before compatibility conveniences become permanent architecture.