V2: Origin based animations, Gesture Builder, New Demo, non-url-based-routing. Massive improvements.

This commit is contained in:
2026-07-25 05:57:26 +00:00
parent 5a514906eb
commit 55dad11b25
49 changed files with 8885 additions and 1 deletions

1
apps/origins-demo/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
dev-dist

View File

@@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<meta name="theme-color" content="#080b12" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-status-bar-style"
content="black-translucent"
/>
<meta name="apple-mobile-web-app-title" content="Origins" />
<meta name="format-detection" content="telephone=no" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" href="/favicon.svg" />
<title>Routeless Origins Lab</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,22 @@
{
"name": "@native-vue-router/origins-demo",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --config vite.config.ts --host",
"build": "vue-tsc -p tsconfig.json --noEmit && vite build --config vite.config.ts",
"preview": "vite preview --config vite.config.ts --host 0.0.0.0 --port 5173"
},
"dependencies": {
"@native-vue-router/core-v2": "0.1.0-experimental.0",
"vue": "^3.5.39"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.7",
"typescript": "~6.0.2",
"vite": "^8.1.1",
"vite-plugin-pwa": "^1.1.0",
"vue-tsc": "^3.3.5"
}
}

View File

@@ -0,0 +1,78 @@
<script setup lang="ts">
import {
OriginScene,
createOriginScene,
originView,
} from "@native-vue-router/core-v2";
import { labEvents } from "./lab-state";
import HubView from "./views/HubView.vue";
/*
* This scene is the entire application state for the experiment. There is no
* Vue Router installation, route table, URL, or hidden RouterView.
*/
const scene = createOriginScene({
initial: originView(HubView, undefined, {
key: "origins-lab",
name: "Origins Lab",
}),
});
</script>
<template>
<div class="demo-shell">
<OriginScene :scene="scene" />
<details class="scene-debug">
<summary>
<span
class="debug-pulse"
:class="{ live: scene.operations.value.length }"
/>
{{ scene.nodes.value.length }} node{{
scene.nodes.value.length === 1 ? "" : "s"
}}
</summary>
<div class="debug-content" aria-live="polite">
<section>
<strong>Scene graph</strong>
<ol>
<li v-for="node in scene.nodes.value" :key="node.key">
<span>
{{ node.view.name }}
<em :class="`node-state node-state--${node.state}`">
{{ node.state }}
</em>
</span>
<code>{{ node.key.split("::").at(-1) }}</code>
</li>
</ol>
</section>
<section v-if="scene.operations.value.length">
<strong>Animation edges</strong>
<div
v-for="operation in scene.operations.value"
:key="operation.id"
class="debug-operation"
>
<span>{{ operation.choreography.name }}</span>
<progress :value="operation.progress" max="1" />
<small>
{{ operation.phase }} ·
{{ Math.round(operation.progress * 100) }}%
</small>
</div>
</section>
<section>
<strong>Recent Vue lifecycle</strong>
<ul class="event-log">
<li v-for="event in labEvents.slice(0, 6)" :key="event.id">
<time>{{ event.time }}</time>
{{ event.message }}
</li>
</ul>
</section>
</div>
</details>
</div>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
defineProps<{
label: string;
instance: string;
status: string;
}>();
</script>
<template>
<div class="instance-card">
<span>{{ label }}</span>
<strong>{{ instance }}</strong>
<small>{{ status }}</small>
</div>
</template>

View File

@@ -0,0 +1,37 @@
export interface DemoPhoto {
id: string;
title: string;
location: string;
gradient: string;
description: string;
}
export const demoPhotos: readonly DemoPhoto[] = [
{
id: "ember",
title: "Afterglow",
location: "Reykjanes · 23:14",
gradient:
"radial-gradient(circle at 72% 25%, #ffcc8a 0, #ff774d 18%, transparent 43%), linear-gradient(145deg, #622c54, #171526 72%)",
description:
"A warm focus portal demonstrates scale, blur, opacity, and rounded-corner effects.",
},
{
id: "tide",
title: "Low tide",
location: "Lofoten · 05:42",
gradient:
"radial-gradient(circle at 25% 25%, #b8f2ff 0, #3f9eb8 24%, transparent 48%), linear-gradient(160deg, #123b50, #07131f 72%)",
description:
"Swipe left while this view is present to create the next photo as another origin.",
},
{
id: "moss",
title: "Quiet giant",
location: "Yakushima · 16:08",
gradient:
"radial-gradient(circle at 64% 30%, #d9ff9f 0, #608b52 22%, transparent 45%), linear-gradient(145deg, #294235, #091812 74%)",
description:
"The like button is component-local state. Move forward, then return to see the same retained instance and state.",
},
];

View File

@@ -0,0 +1,49 @@
import { onBeforeUnmount, onMounted, reactive, ref } from "vue";
export interface LabEvent {
id: number;
message: string;
time: string;
}
let instanceSequence = 0;
let eventSequence = 0;
export const labEvents = reactive<LabEvent[]>([]);
export function recordLabEvent(message: string) {
labEvents.unshift({
id: ++eventSequence,
message,
time: new Date().toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}),
});
if (labEvents.length > 18) labEvents.length = 18;
}
/**
* Give every Vue setup execution a visible identity. A scene-edge collapse
* and a forward push both retain the ID. A committed back only unmounts the
* entry being popped, then reveals the exact previous instance.
*/
export function useDemoInstance(label: string) {
const instance = `${label.slice(0, 2).toUpperCase()}-${String(
++instanceSequence,
).padStart(2, "0")}`;
const status = ref("created");
recordLabEvent(`${instance} created`);
onMounted(() => {
status.value = "mounted";
recordLabEvent(`${instance} mounted`);
});
onBeforeUnmount(() => {
status.value = "unmounting";
recordLabEvent(`${instance} unmounted`);
});
return { instance, status };
}

View File

@@ -0,0 +1,13 @@
import { createApp } from "vue";
import { registerSW } from "virtual:pwa-register";
import App from "./App.vue";
import "./style.css";
/*
* Registration is deliberately immediate so an installed app becomes
* offline-capable during its first session. `autoUpdate` in the Vite plugin
* activates a fresh build without requiring a custom update prompt yet.
*/
registerSW({ immediate: true });
createApp(App).mount("#app");

View File

@@ -0,0 +1,325 @@
import { defineOriginChoreography } from "@native-vue-router/core-v2";
const percent = (value: number) => `${(value * 100).toFixed(3)}%`;
/** A bottom-up cover similar to presenting a native media player. */
export const coverUp = defineOriginChoreography({
name: "cover-up",
commitThreshold: 0.3,
commitVelocity: 0.75,
effects: ({ progress }) => ({
source: {
transform: `translate3d(0, ${percent(progress * -0.035)}, 0) scale(${1 - progress * 0.055})`,
opacity: 1 - progress * 0.22,
style: { filter: `saturate(${1 - progress * 0.3})` },
},
target: {
transform: `translate3d(0, ${percent(1 - progress)}, 0)`,
style: {
borderRadius: `${(1 - progress) * 30}px`,
boxShadow: `0 -24px 80px rgb(0 0 0 / ${progress * 0.38})`,
},
},
}),
});
/** Reverse of coverUp. The retained origin instance is revealed underneath. */
export const coverDown = defineOriginChoreography({
name: "cover-down",
commitThreshold: 0.28,
commitVelocity: 0.72,
effects: ({ progress }) => ({
source: {
transform: `translate3d(0, ${percent(progress)}, 0)`,
style: { borderRadius: `${progress * 30}px` },
},
target: {
transform: `translate3d(0, ${percent(-0.035 + progress * 0.035)}, 0) scale(${0.945 + progress * 0.055})`,
opacity: 0.78 + progress * 0.22,
style: { filter: `saturate(${0.7 + progress * 0.3})` },
},
}),
});
/**
* A scale-and-focus transition. Filters use local-last CSS composition while
* geometry remains independently composable with any ancestor operation.
*/
export const focusPortal = defineOriginChoreography({
name: "focus-portal",
commitThreshold: 0.32,
effects: ({ progress }) => ({
source: {
transform: `scale(${1 - progress * 0.12})`,
opacity: 1 - progress * 0.55,
style: { filter: `blur(${progress * 8}px)` },
},
target: {
transform: `translate3d(0, ${percent((1 - progress) * 0.08)}, 0) scale(${0.72 + progress * 0.28})`,
opacity: progress,
style: {
borderRadius: `${(1 - progress) * 42}px`,
filter: `blur(${(1 - progress) * 3}px)`,
},
},
}),
});
export const unfocusPortal = defineOriginChoreography({
name: "unfocus-portal",
commitThreshold: 0.3,
effects: ({ progress }) => ({
source: {
transform: `translate3d(0, ${percent(progress * 0.08)}, 0) scale(${1 - progress * 0.28})`,
opacity: 1 - progress,
style: {
borderRadius: `${progress * 42}px`,
filter: `blur(${progress * 3}px)`,
},
},
target: {
transform: `scale(${0.88 + progress * 0.12})`,
opacity: 0.45 + progress * 0.55,
style: { filter: `blur(${(1 - progress) * 8}px)` },
},
}),
});
/** A deliberately expressive transition for the four-direction gesture lab. */
export const cardFlip = defineOriginChoreography({
name: "card-flip",
commitThreshold: 0.42,
effects: ({ progress }) => ({
source: {
transform: `perspective(900px) rotateY(${-progress * 86}deg) scale(${1 - progress * 0.08})`,
opacity: 1 - progress * 0.68,
style: { transformOrigin: "left center" },
},
target: {
transform: `perspective(900px) rotateY(${(1 - progress) * 86}deg) scale(${0.92 + progress * 0.08})`,
opacity: 0.3 + progress * 0.7,
style: { transformOrigin: "right center" },
},
}),
});
/**
* The frame contribution affects both sides of the edge. If this operation is
* chained, descendants inherit the same small vertical arc.
*/
export const arcSlide = defineOriginChoreography({
name: "arc-slide",
commitThreshold: 0.34,
commitVelocity: 0.8,
effects: ({ progress }) => ({
frame: {
transform: `translate3d(0, ${-Math.sin(progress * Math.PI) * 9}px, 0)`,
},
source: {
transform: `translate3d(${percent(progress * -0.28)}, 0, 0) rotate(${-progress * 1.5}deg) scale(${1 - progress * 0.035})`,
opacity: 1 - progress * 0.22,
},
target: {
transform: `translate3d(${percent(1 - progress)}, 0, 0) rotate(${(1 - progress) * 2.5}deg)`,
},
}),
});
export const rise = defineOriginChoreography({
name: "rise",
commitThreshold: 0.33,
effects: ({ progress }) => ({
source: {
transform: `translate3d(0, ${percent(progress * -0.18)}, 0) scale(${1 - progress * 0.04})`,
opacity: 1 - progress * 0.3,
},
target: {
transform: `translate3d(0, ${percent(1 - progress)}, 0)`,
},
}),
});
export const fall = defineOriginChoreography({
name: "fall",
commitThreshold: 0.33,
effects: ({ progress }) => ({
source: {
transform: `translate3d(0, ${percent(progress)}, 0)`,
},
target: {
transform: `translate3d(0, ${percent(-0.18 + progress * 0.18)}, 0) scale(${0.96 + progress * 0.04})`,
opacity: 0.7 + progress * 0.3,
},
}),
});
/**
* Reveal a full-screen overlay from above while its actual dialog remains
* anchored to the left. The gesture's custom start predicate lives in the
* originating view; choreography remains concerned only with movement.
*/
export const dropEdgeDialog = defineOriginChoreography({
name: "drop-edge-dialog",
commitThreshold: 0.24,
commitVelocity: 0.62,
effects: ({ progress }) => ({
source: {
transform: `scale(${1 - progress * 0.025})`,
opacity: 1 - progress * 0.38,
style: {
filter: `saturate(${1 - progress * 0.35})`,
},
},
target: {
transform: `translate3d(0, ${percent((1 - progress) * -0.018)}, 0)`,
opacity: 1,
style: {
clipPath: `inset(0 0 ${percent(1 - progress)} 0)`,
filter: `drop-shadow(0 28px 70px rgb(0 0 0 / ${progress * 0.55}))`,
},
},
}),
});
/** Reverse the edge dialog upward and reveal its retained origin instance. */
export const liftEdgeDialog = defineOriginChoreography({
name: "lift-edge-dialog",
commitThreshold: 0.24,
commitVelocity: 0.62,
effects: ({ progress }) => ({
source: {
transform: `translate3d(0, ${percent(progress * -0.018)}, 0)`,
opacity: 1,
style: {
clipPath: `inset(0 0 ${percent(progress)} 0)`,
},
},
target: {
transform: `scale(${0.975 + progress * 0.025})`,
opacity: 0.62 + progress * 0.38,
style: {
filter: `saturate(${0.65 + progress * 0.35})`,
},
},
}),
});
/**
* Compact horizontal motion for a nested carousel. Its smaller source travel
* keeps the retained slide visible behind the incoming card.
*/
export const nestedCarouselForward = defineOriginChoreography({
name: "nested-carousel-forward",
commitThreshold: 0.3,
commitVelocity: 0.7,
effects: ({ progress }) => ({
source: {
transform: `translate3d(${percent(progress * -0.18)}, 0, 0) scale(${1 - progress * 0.045})`,
opacity: 1 - progress * 0.35,
style: { filter: `saturate(${1 - progress * 0.25})` },
},
target: {
transform: `translate3d(${percent(1 - progress)}, 0, 0)`,
style: {
borderRadius: `${(1 - progress) * 28}px`,
boxShadow: `-24px 0 60px rgb(0 0 0 / ${progress * 0.3})`,
},
},
}),
});
/** Reverse reveal for the cooperative nested carousel. */
export const nestedCarouselBack = defineOriginChoreography({
name: "nested-carousel-back",
commitThreshold: 0.3,
commitVelocity: 0.7,
effects: ({ progress }) => ({
source: {
transform: `translate3d(${percent(progress)}, 0, 0)`,
style: { borderRadius: `${progress * 28}px` },
},
target: {
transform: `translate3d(${percent(-0.18 + progress * 0.18)}, 0, 0) scale(${0.955 + progress * 0.045})`,
opacity: 0.65 + progress * 0.35,
style: { filter: `saturate(${0.75 + progress * 0.25})` },
},
}),
});
/**
* Vertical nested-deck motion with a shared sideways arc. The frame effect
* demonstrates that nested scenes retain the same composition semantics.
*/
export const nestedDeckForward = defineOriginChoreography({
name: "nested-deck-forward",
commitThreshold: 0.32,
effects: ({ progress }) => ({
frame: {
transform: `translate3d(${Math.sin(progress * Math.PI) * 10}px, 0, 0)`,
},
source: {
transform: `translate3d(0, ${percent(progress * -0.12)}, 0) rotate(${-progress * 2}deg) scale(${1 - progress * 0.06})`,
opacity: 1 - progress * 0.4,
},
target: {
transform: `translate3d(0, ${percent(1 - progress)}, 0) rotate(${(1 - progress) * 3}deg)`,
},
}),
});
/** Downward reverse of the nested vertical deck. */
export const nestedDeckBack = defineOriginChoreography({
name: "nested-deck-back",
commitThreshold: 0.32,
effects: ({ progress }) => ({
frame: {
transform: `translate3d(${-Math.sin(progress * Math.PI) * 10}px, 0, 0)`,
},
source: {
transform: `translate3d(0, ${percent(progress)}, 0) rotate(${progress * 3}deg)`,
},
target: {
transform: `translate3d(0, ${percent(-0.12 + progress * 0.12)}, 0) rotate(${(1 - progress) * -2}deg) scale(${0.94 + progress * 0.06})`,
opacity: 0.6 + progress * 0.4,
},
}),
});
/**
* Intentionally dramatic cube turn used by the conflict case so it is obvious
* when the child scene, rather than the page scene, owns the gesture.
*/
export const nestedCubeForward = defineOriginChoreography({
name: "nested-cube-forward",
commitThreshold: 0.38,
effects: ({ progress }) => ({
source: {
transform: `perspective(720px) translate3d(${percent(progress * -0.5)}, 0, 0) rotateY(${progress * 72}deg)`,
opacity: 1 - progress * 0.55,
style: { transformOrigin: "right center" },
},
target: {
transform: `perspective(720px) translate3d(${percent((1 - progress) * 0.5)}, 0, 0) rotateY(${(progress - 1) * 72}deg)`,
opacity: 0.45 + progress * 0.55,
style: { transformOrigin: "left center" },
},
}),
});
/** Reverse cube turn for retained slides in the conflict case. */
export const nestedCubeBack = defineOriginChoreography({
name: "nested-cube-back",
commitThreshold: 0.38,
effects: ({ progress }) => ({
source: {
transform: `perspective(720px) translate3d(${percent(progress * 0.5)}, 0, 0) rotateY(${-progress * 72}deg)`,
opacity: 1 - progress * 0.55,
style: { transformOrigin: "left center" },
},
target: {
transform: `perspective(720px) translate3d(${percent((progress - 1) * 0.5)}, 0, 0) rotateY(${(1 - progress) * 72}deg)`,
opacity: 0.45 + progress * 0.55,
style: { transformOrigin: "right center" },
},
}),
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import { nextTick, ref } from "vue";
import { back, slideRight, useOrigin } from "@native-vue-router/core-v2";
import InstanceCard from "../components/InstanceCard.vue";
import { useDemoInstance } from "../lab-state";
const origin = useOrigin();
const identity = useDemoInstance("Chat");
const draft = ref("");
const messages = ref([
"This page deliberately owns no edge recognizer.",
"Try swiping right. Nothing should happen.",
]);
function goBack() {
if (origin.context.value.canGoBack) void origin.perform(back(slideRight));
}
async function send() {
const message = draft.value.trim();
if (!message) return;
messages.value.push(message);
draft.value = "";
await nextTick();
}
</script>
<template>
<!--
No gesture binding is used here. Gesture absence is the policy, rather
than a global back gesture followed by an exception.
-->
<div class="chat-view">
<header class="chat-header">
<button type="button" @click="goBack"></button>
<div class="chat-avatar">M</div>
<div>
<strong>Maya</strong>
<span>online</span>
</div>
<span class="chat-lock">edge locked</span>
</header>
<main class="messages">
<div
v-for="(message, index) in messages"
:key="`${index}-${message}`"
class="message"
:class="{ 'message--mine': index > 1 }"
>
{{ message }}
</div>
<InstanceCard
label="Chat instance"
:instance="identity.instance"
:status="identity.status.value"
/>
</main>
<form class="composer" @submit.prevent="send">
<input v-model="draft" placeholder="Write something…" />
<button type="submit">Send</button>
</form>
</div>
</template>

View File

@@ -0,0 +1,116 @@
<script setup lang="ts">
import {
above,
back,
gesture,
OriginGestureSurface,
originView,
useOrigin,
} from "@native-vue-router/core-v2";
import InstanceCard from "../components/InstanceCard.vue";
import { useDemoInstance } from "../lab-state";
import { cardFlip, rise, unfocusPortal } from "../motions";
import DirectionResultView from "./DirectionResultView.vue";
const origin = useOrigin();
const identity = useDemoInstance("Direction");
const eastView = () =>
originView(
DirectionResultView,
{
direction: "east",
heading: "Horizontal declarations can own a full-page flip.",
accent: "#38d8ff",
createdBy: identity.instance,
},
{ key: "direction-east", name: "East Result" },
);
const openEast = () => above(eastView(), cardFlip);
const northView = () =>
originView(
DirectionResultView,
{
direction: "north",
heading: "The same origin can create a completely different view upward.",
accent: "#b9ff66",
createdBy: identity.instance,
},
{ key: "direction-north", name: "North Result" },
);
const openNorth = () => above(northView(), rise);
const flipGesture = gesture.to
.left()
.navigate(() => above(eastView()))
.animate(cardFlip);
const riseGesture = gesture.to
.up()
.navigate(() => above(northView()))
.animate(rise);
const backGesture = gesture.from
.left("clamp(24px, 8%, 64px)")
.to.right()
.navigate((context) => (context.canGoBack ? back() : null))
.animate(unfocusPortal);
const gestures = [flipGesture, riseGesture, backGesture] as const;
function perform(action: ReturnType<typeof openEast>) {
void origin.perform(action);
}
</script>
<template>
<OriginGestureSurface
as="main"
class="lab-page direction-view"
:gestures="gestures"
>
<div class="eyebrow">Lab 02 · Direction matrix</div>
<h1>One component. Three explicit choices.</h1>
<p>
Swipe left for a perspective flip, swipe up for a rising cover, or use the
left edge to go back. None of these gestures exist outside this view.
</p>
<div class="direction-compass">
<button
type="button"
data-origin-gesture="ignore"
class="compass-action compass-action--up"
@click="perform(openNorth())"
>
<span></span>
<strong>Rise</strong>
<small>target above</small>
</button>
<div class="compass-core">
<span>origin</span>
<strong>{{ identity.instance }}</strong>
</div>
<button
type="button"
data-origin-gesture="ignore"
class="compass-action compass-action--left"
@click="perform(openEast())"
>
<span></span>
<strong>Flip left</strong>
<small>custom perspective</small>
</button>
</div>
<InstanceCard
label="Direction view"
:instance="identity.instance"
:status="identity.status.value"
/>
<div class="gesture-map">
<span> full-surface target</span>
<span> full-surface target</span>
<span>left edge history back</span>
</div>
<div class="edge-marker edge-marker--left">back edge</div>
</OriginGestureSurface>
</template>

View File

@@ -0,0 +1,87 @@
<script setup lang="ts">
import {
OriginGesture,
back,
gesture,
slideRight,
useOrigin,
type OriginContext,
} from "@native-vue-router/core-v2";
import InstanceCard from "../components/InstanceCard.vue";
import { useDemoInstance } from "../lab-state";
import { fall } from "../motions";
const props = defineProps<{
direction: "east" | "north";
heading: string;
accent: string;
createdBy?: string;
}>();
const origin = useOrigin();
const identity = useDemoInstance(props.direction === "east" ? "East" : "North");
const goBack = (context: OriginContext) => {
if (!context.canGoBack) return null;
return back(props.direction === "north" ? fall : slideRight);
};
const returnGesture =
props.direction === "east"
? gesture.from
.left("clamp(24px, 8%, 64px)")
.to.right()
.navigate((context) => (context.canGoBack ? back() : null))
.animate(slideRight)
: gesture.to
.down()
.navigate((context) => (context.canGoBack ? back() : null))
.animate(fall);
function backWithButton() {
const action = goBack(origin.context.value);
if (action) void origin.perform(action);
}
</script>
<template>
<OriginGesture
class="lab-page direction-result"
:class="`direction-result--${direction}`"
:style="{ '--result-accent': accent }"
:gesture="returnGesture"
>
<div class="result-orbit" />
<div class="eyebrow">Resolved from {{ direction }}</div>
<h1>{{ heading }}</h1>
<p>
This component was created by <strong>{{ createdBy }}</strong
>. Its own setup has now produced <strong>{{ identity.instance }}</strong
>.
</p>
<InstanceCard
label="Result instance"
:instance="identity.instance"
:status="identity.status.value"
/>
<button
class="primary-action"
type="button"
data-origin-gesture="ignore"
@click="backWithButton"
>
Return to the retained direction instance
</button>
<div
class="gesture-hint"
:class="{ 'gesture-hint--back': direction === 'east' }"
>
<template v-if="direction === 'north'"
>Swipe down to return <span></span></template
>
<template v-else><span></span> Swipe from the left edge</template>
</div>
<div v-if="direction === 'east'" class="edge-marker edge-marker--left">
back edge
</div>
</OriginGesture>
</template>

View File

@@ -0,0 +1,116 @@
<script setup lang="ts">
import { ref } from "vue";
import {
OriginGesture,
back,
gesture,
useOrigin,
type OriginContext,
} from "@native-vue-router/core-v2";
import InstanceCard from "../components/InstanceCard.vue";
import { useDemoInstance } from "../lab-state";
import { liftEdgeDialog } from "../motions";
defineProps<{
openedBy?: string;
predicate?: string;
}>();
const origin = useOrigin();
const identity = useDemoInstance("Dialog");
const selected = ref("Motion");
const dismiss = (context: OriginContext) =>
context.canGoBack ? back(liftEdgeDialog) : null;
const dismissGesture = gesture.to
.up()
.navigate((context) => (context.canGoBack ? back() : null))
.animate(liftEdgeDialog);
function close() {
const action = dismiss(origin.context.value);
if (action) void origin.perform(action);
}
</script>
<template>
<OriginGesture class="edge-dialog-overlay" :gesture="dismissGesture">
<button
class="edge-dialog-backdrop"
type="button"
data-origin-gesture="ignore"
aria-label="Close edge dialog"
@click="close"
/>
<section
class="edge-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="edge-dialog-title"
>
<header>
<div>
<div class="eyebrow">Custom-origin dialog</div>
<h1 id="edge-dialog-title">Dropped from the top.</h1>
</div>
<button
type="button"
data-origin-gesture="ignore"
aria-label="Close dialog"
@click="close"
>
×
</button>
</header>
<p>
The target is a normal scene component. Its content is anchored left,
while the operation moves the target frame from top to bottom.
</p>
<dl class="dialog-facts">
<div>
<dt>Admitted by</dt>
<dd>{{ predicate }}</dd>
</div>
<div>
<dt>Origin instance</dt>
<dd>{{ openedBy }}</dd>
</div>
<div>
<dt>Placement</dt>
<dd>above</dd>
</div>
</dl>
<fieldset>
<legend>Dialog-local state</legend>
<button
v-for="option in ['Motion', 'Gesture', 'Scene']"
:key="option"
type="button"
data-origin-gesture="ignore"
:class="{ selected: selected === option }"
@click="selected = option"
>
{{ option }}
</button>
</fieldset>
<InstanceCard
label="Dialog instance"
:instance="identity.instance"
:status="identity.status.value"
/>
<footer>
<strong>Swipe up anywhere on the panel to dismiss.</strong>
<small
>The reverse operation reveals the retained origin instance.</small
>
</footer>
</section>
</OriginGesture>
</template>

View File

@@ -0,0 +1,125 @@
<script setup lang="ts">
import {
above,
back,
gesture,
OriginGestureSurface,
originView,
useOrigin,
type OriginContext,
} from "@native-vue-router/core-v2";
import InstanceCard from "../components/InstanceCard.vue";
import { useDemoInstance } from "../lab-state";
import { dropEdgeDialog, unfocusPortal } from "../motions";
import EdgeDialogView from "./EdgeDialogView.vue";
const origin = useOrigin();
const identity = useDemoInstance("Predicate");
/*
* This is deliberately not the built-in `edge` option. A down gesture's
* built-in edge would be the top edge, while this lab wants down movement to
* begin at the left edge. The predicate can describe that cross-axis rule—or
* any safe area, shape, percentage, or exclusion zone the application needs.
*/
function dialogEdgeWidth(host: HTMLElement) {
return Math.min(72, Math.max(36, host.clientWidth * 0.08));
}
const dialogView = () =>
originView(
EdgeDialogView,
{
openedBy: identity.instance,
predicate: "x ≤ clamp(36px, 8%, 72px)",
},
{ key: "edge-dialog", name: "Edge Dialog" },
);
const openDialog = () => above(dialogView(), dropEdgeDialog);
const goBack = (context: OriginContext) =>
context.canGoBack ? back(unfocusPortal) : null;
const dialogGesture = gesture.from
.when(({ point, host }) => point.localX <= dialogEdgeWidth(host))
.to.down()
.navigate(() => above(dialogView()))
.animate(dropEdgeDialog);
/*
* The same physical left edge also supports a conventional rightward back
* gesture. Both recognizers see pointer-down; their axis checks decide which
* one captures once movement becomes unambiguous.
*/
const backGesture = gesture.from
.left("clamp(36px, 8%, 72px)")
.to.right()
.navigate((context) => (context.canGoBack ? back() : null))
.animate(unfocusPortal);
const gestures = [dialogGesture, backGesture] as const;
function backWithButton() {
const action = goBack(origin.context.value);
if (action) void origin.perform(action);
}
</script>
<template>
<OriginGestureSurface
as="main"
class="lab-page predicate-view"
:gestures="gestures"
>
<button
class="view-back"
type="button"
data-origin-gesture="ignore"
@click="backWithButton"
>
Labs
</button>
<div class="predicate-copy">
<div class="eyebrow">Lab 06 · Predicate edge</div>
<h1>Direction and starting edge are independent.</h1>
<p>
Begin inside the illuminated left rail and drag <strong>down</strong>. A
custom predicate admits the pointer; the normal direction recognizer
still handles axis capture, progress, velocity, and release.
</p>
<div class="predicate-code">
<span>start predicate</span>
<code>x clamp(36px, 8%, 72px)</code>
<small>movement: down · built-in edge: intentionally omitted</small>
</div>
<InstanceCard
label="Predicate origin"
:instance="identity.instance"
:status="identity.status.value"
/>
<button
class="primary-action"
type="button"
data-origin-gesture="ignore"
@click="origin.perform(openDialog())"
>
Open accessibly without dragging
</button>
</div>
<div class="predicate-edge" aria-hidden="true">
<span>custom left-edge predicate</span>
<div class="predicate-arrow"></div>
<small>start here · pull down</small>
</div>
<div class="predicate-axis-note">
A rightward drag on this same rail still performs edge-back.
</div>
</OriginGestureSurface>
</template>

View File

@@ -0,0 +1,89 @@
<script setup lang="ts">
import {
above,
back,
gesture,
OriginGestureSurface,
originView,
slideLeft,
slideRight,
useOrigin,
type OriginContext,
} from "@native-vue-router/core-v2";
import InstanceCard from "../components/InstanceCard.vue";
import { useDemoInstance } from "../lab-state";
import SecondView from "./SecondView.vue";
const origin = useOrigin();
const identity = useDemoInstance("X");
const secondView = () =>
originView(
SecondView,
{
openedAt: new Date().toLocaleTimeString(),
createdBy: identity.instance,
},
{ key: "second", name: "Second" },
);
const createSecond = () => above(secondView(), slideLeft);
const goBack = (context: OriginContext) =>
context.canGoBack ? back(slideRight) : null;
const forwardGesture = gesture.to
.left()
.navigate(() => above(secondView()))
.animate(slideLeft);
const backGesture = gesture
// .from.left("clamp(24px, 8%, 64px)")
// .from.left("20%")
.to.right()
.navigate((context) => (context.canGoBack ? back() : null))
.animate(slideRight);
const gestures = [forwardGesture, backGesture] as const;
function backWithButton() {
const action = goBack(origin.context.value);
if (action) void origin.perform(action);
}
</script>
<template>
<OriginGestureSurface
as="main"
class="lab-page chain-view view-one"
:gestures="gestures"
>
<button
class="view-back"
type="button"
data-origin-gesture="ignore"
@click="backWithButton"
>
Labs
</button>
<div class="eyebrow">Chain stress test · Origin X</div>
<h1>Every component starts its own movement.</h1>
<p>
Drag this screen left. Release it, then immediately drag the blue screen
while this spring is still settling. Keep going to build a four-node
scene.
</p>
<InstanceCard
label="X Vue instance"
:instance="identity.instance"
:status="identity.status.value"
/>
<button
class="primary-action"
data-origin-gesture="ignore"
type="button"
@click="origin.perform(createSecond())"
>
Open Y without a gesture
</button>
<div class="gesture-hint">Swipe left <span></span></div>
<div class="edge-marker edge-marker--left">back edge</div>
</OriginGestureSurface>
</template>

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
import {
OriginGesture,
back,
gesture,
slideRight,
useOrigin,
type OriginContext,
} from "@native-vue-router/core-v2";
import InstanceCard from "../components/InstanceCard.vue";
import { useDemoInstance } from "../lab-state";
defineProps<{ chain?: string }>();
const origin = useOrigin();
const identity = useDemoInstance("Omega");
const goBack = (context: OriginContext) =>
context.canGoBack ? back(slideRight) : null;
const backGesture = gesture
// .from.left("clamp(24px, 8%, 64px)")
// .from.left("20%")
.to.right()
.navigate((context) => (context.canGoBack ? back() : null))
.animate(slideRight);
function backWithButton() {
const action = goBack(origin.context.value);
if (action) void origin.perform(action);
}
</script>
<template>
<OriginGesture class="lab-page chain-view view-four" :gesture="backGesture">
<div class="eyebrow">Chain stress test · Origin Ω</div>
<h1>Four independently mounted components. Three live edges.</h1>
<p>
If you moved quickly enough, the inspector showed X, Y, Z, and Ω at once.
Each component stayed a flat sibling while inherited transforms composed
mathematically.
</p>
<div class="proof">
Reported ancestry <strong>{{ chain }}</strong>
</div>
<InstanceCard
label="Ω Vue instance"
:instance="identity.instance"
:status="identity.status.value"
/>
<button
class="primary-action"
type="button"
data-origin-gesture="ignore"
@click="backWithButton"
>
Return to the retained Z instance
</button>
<div class="gesture-hint gesture-hint--back">
<span></span> Or swipe from the left edge
</div>
<div class="edge-marker edge-marker--left">back edge</div>
</OriginGesture>
</template>

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
import {
OriginGesture,
above,
back,
gesture,
originView,
useOrigin,
type OriginContext,
} from "@native-vue-router/core-v2";
import InstanceCard from "../components/InstanceCard.vue";
import { demoPhotos, type DemoPhoto } from "../gallery-data";
import { useDemoInstance } from "../lab-state";
import { focusPortal, unfocusPortal } from "../motions";
import PhotoDetailView from "./PhotoDetailView.vue";
const origin = useOrigin();
const identity = useDemoInstance("Gallery");
const photoAction = (photo: DemoPhoto, index: number) =>
above(
originView(
PhotoDetailView,
{ photo, index },
{ key: `photo-${photo.id}`, name: photo.title },
),
focusPortal,
);
const goBack = (context: OriginContext) =>
context.canGoBack ? back(unfocusPortal) : null;
const backGesture = gesture.from
.left("clamp(24px, 8%, 64px)")
.to.right()
.navigate((context) => (context.canGoBack ? back() : null))
.animate(unfocusPortal);
function backWithButton() {
const action = goBack(origin.context.value);
if (action) void origin.perform(action);
}
</script>
<template>
<OriginGesture class="lab-page gallery-view" :gesture="backGesture">
<header class="section-header">
<div>
<button
class="view-back"
type="button"
data-origin-gesture="ignore"
@click="backWithButton"
>
Labs
</button>
<div class="eyebrow">Lab 03 · Focus gallery</div>
<h1>Events can originate motion without being gestures.</h1>
<p>
Select a card to run the same scene primitive programmatically. Once
inside, the destination declares its own traversal gestures.
</p>
</div>
<InstanceCard
label="Gallery instance"
:instance="identity.instance"
:status="identity.status.value"
/>
</header>
<div class="photo-grid">
<button
v-for="(photo, index) in demoPhotos"
:key="photo.id"
type="button"
data-origin-gesture="ignore"
class="photo-card"
:style="{ background: photo.gradient }"
@click="origin.perform(photoAction(photo, index))"
>
<span>{{ String(index + 1).padStart(2, "0") }}</span>
<strong>{{ photo.title }}</strong>
<small>{{ photo.location }}</small>
</button>
</div>
<div class="edge-marker edge-marker--left">back edge</div>
</OriginGesture>
</template>

View File

@@ -0,0 +1,177 @@
<script setup lang="ts">
import {
OriginGesture,
above,
gesture,
originView,
slideLeft,
useOrigin,
type OriginChoreography,
} from "@native-vue-router/core-v2";
import InstanceCard from "../components/InstanceCard.vue";
import { useDemoInstance } from "../lab-state";
import { coverUp, focusPortal } from "../motions";
import ChatView from "./ChatView.vue";
import DirectionLabView from "./DirectionLabView.vue";
import EdgePredicateView from "./EdgePredicateView.vue";
import FirstView from "./FirstView.vue";
import GalleryView from "./GalleryView.vue";
import NestedScenesView from "./NestedScenesView.vue";
import PlayerView from "./PlayerView.vue";
const origin = useOrigin();
const identity = useDemoInstance("Hub");
function viewFor(component: Parameters<typeof originView>[0], name: string) {
return originView(component, undefined, {
key: name.toLowerCase().replaceAll(" ", "-"),
name,
});
}
function actionFor(
component: Parameters<typeof originView>[0],
name: string,
choreography: OriginChoreography,
) {
return above(viewFor(component, name), choreography);
}
/*
* No `.from` is declared: every eligible pointer-down on the Hub can become
* this leftward gesture once movement passes the direction lock.
*/
const enterChainGesture = gesture.to
.left()
// .complete(
// ({ progress, velocity }) =>
// progress >= 0.3 || (progress >= 0.05 && velocity >= 0.8),
// )
.navigate(() => above(viewFor(FirstView, "Chain X")))
.animate(slideLeft);
const openChain = () => actionFor(FirstView, "Chain X", slideLeft);
const openDirections = () =>
actionFor(DirectionLabView, "Direction Lab", focusPortal);
const openGallery = () => actionFor(GalleryView, "Gallery", focusPortal);
const openPlayer = () => actionFor(PlayerView, "Player", coverUp);
const openChat = () => actionFor(ChatView, "Locked Chat", slideLeft);
const openPredicate = () =>
actionFor(EdgePredicateView, "Predicate Edge", focusPortal);
const openNestedScenes = () =>
actionFor(NestedScenesView, "Nested Scenes", focusPortal);
const labs = [
{
number: "01",
title: "Concurrent chain",
description: "Swipe through X→Y→Z→Ω before the preceding springs settle.",
tags: ["4 live nodes", "frame composition"],
action: openChain,
accent: "violet",
},
{
number: "02",
title: "Direction matrix",
description:
"One component declares horizontal, vertical, and edge-only behavior.",
tags: ["multi-axis", "custom flip"],
action: openDirections,
accent: "cyan",
},
{
number: "03",
title: "Focus gallery",
description:
"Programmatic card selection and gesture-driven photo traversal.",
tags: ["portal", "local state"],
action: openGallery,
accent: "amber",
},
{
number: "04",
title: "Native player",
description:
"A vertical cover with media state and intentionally no back edge.",
tags: ["vertical dismiss", "gesture policy"],
action: openPlayer,
accent: "green",
},
{
number: "05",
title: "Locked chat",
description:
"A Snapchat-style page that exposes only an explicit back button.",
tags: ["blocked edge", "form state"],
action: openChat,
accent: "rose",
},
{
number: "06",
title: "Predicate edge",
description: "Swipe down from a dynamic left-edge region to drop a dialog.",
tags: ["custom hit test", "axis arbitration"],
action: openPredicate,
accent: "lime",
},
{
number: "07",
title: "Nested scenes",
description:
"Carousels and decks with local history—including deliberate gesture conflicts.",
tags: ["carousel", "nested ownership"],
action: openNestedScenes,
accent: "blue",
},
] as const;
</script>
<template>
<OriginGesture class="lab-page hub-view" :gesture="enterChainGesture">
<keep-alive>
<header class="hub-header">
<div>
<div class="eyebrow">Routeless origins · interactive field guide</div>
<h1>A navigation engine made of component-owned movement.</h1>
<p>
No route table decides what this screen can do. Every lab below
creates its target, chooses its choreography, and declares its own
gesture policy.
</p>
</div>
<InstanceCard
label="Hub instance"
:instance="identity.instance"
:status="identity.status.value"
/>
</header>
<main class="lab-grid">
<button
v-for="lab in labs"
:key="lab.number"
type="button"
data-origin-gesture="ignore"
class="lab-card"
:class="`lab-card--${lab.accent}`"
@click="origin.perform(lab.action())"
>
<span class="lab-number">{{ lab.number }}</span>
<span class="lab-copy">
<strong>{{ lab.title }}</strong>
<small>{{ lab.description }}</small>
</span>
<span class="tag-row">
<em v-for="tag in lab.tags" :key="tag">{{ tag }}</em>
</span>
<span class="lab-arrow"></span>
</button>
</main>
<footer class="hub-footer">
<span>Tip: swipe anywhere left to enter the chain stress test.</span>
<span>The inspector in the corner exposes live nodes and edges.</span>
</footer>
</keep-alive>
</OriginGesture>
</template>

View File

@@ -0,0 +1,166 @@
<script setup lang="ts">
import { ref } from "vue";
import {
back,
forward,
gesture,
OriginGestureSurface,
useOrigin,
type OriginChoreography,
type OriginGestureDirectedBuilder,
type OriginView,
} from "@native-vue-router/core-v2";
import InstanceCard from "../components/InstanceCard.vue";
import { useDemoInstance } from "../lab-state";
import {
nestedCarouselBack,
nestedCarouselForward,
nestedCubeBack,
nestedCubeForward,
nestedDeckBack,
nestedDeckForward,
} from "../motions";
type NestedSceneKind = "cooperative" | "vertical" | "conflict";
const props = defineProps<{
kind: NestedSceneKind;
index: number;
total: number;
eyebrow: string;
title: string;
description: string;
accent: string;
next?: () => OriginView;
}>();
const origin = useOrigin();
const identity = useDemoInstance(`Nested ${props.kind} ${props.index + 1}`);
const localLikes = ref(0);
const vertical = props.kind === "vertical";
const forwardMotion: OriginChoreography = vertical
? nestedDeckForward
: props.kind === "conflict"
? nestedCubeForward
: nestedCarouselForward;
const backMotion: OriginChoreography = vertical
? nestedDeckBack
: props.kind === "conflict"
? nestedCubeBack
: nestedCarouselBack;
/*
* Cooperative cases deliberately leave the outer page's first 44 pixels
* untouched. The conflict case starts anywhere, so its pointer-down handler
* stops propagation before it knows whether `back()` is available.
*/
function movementBuilder(
direction: "forward" | "back",
): OriginGestureDirectedBuilder {
const to = vertical
? direction === "forward"
? "up"
: "down"
: direction === "forward"
? "left"
: "right";
const start =
props.kind === "conflict"
? gesture.to
: gesture.from.when(({ point, bounds }) => {
const parentRail = Math.min(52, Math.max(22, bounds.width * 0.12));
return point.localX > parentRail;
}).to;
return start[to]();
}
const forwardGesture = movementBuilder("forward")
.navigate(() => {
const target = props.next?.();
return target ? forward(target) : null;
})
.animate(forwardMotion);
const backGesture = movementBuilder("back")
.navigate((context) => (context.canGoBack ? back() : null))
.animate(backMotion);
const gestures = [forwardGesture, backGesture] as const;
function nextWithButton() {
const target = props.next?.();
if (target) void origin.perform(forward(target, forwardMotion));
}
function backWithButton() {
if (origin.context.value.canGoBack) void origin.perform(back(backMotion));
}
</script>
<template>
<OriginGestureSurface
as="article"
class="nested-slide"
:class="`nested-slide--${kind}`"
:data-nested-kind="kind"
:data-nested-index="index"
:style="{ '--nested-accent': accent }"
:gestures="gestures"
>
<div class="nested-slide__orb" aria-hidden="true" />
<header>
<span>{{ eyebrow }}</span>
<strong>{{ index + 1 }} / {{ total }}</strong>
</header>
<div class="nested-slide__copy">
<h3>{{ title }}</h3>
<p>{{ description }}</p>
</div>
<div class="nested-slide__state">
<InstanceCard
label="Nested instance"
:instance="identity.instance"
:status="identity.status.value"
/>
<button
type="button"
data-origin-gesture="ignore"
@click="localLikes += 1"
>
Local state · {{ localLikes }}
</button>
</div>
<footer>
<button
type="button"
data-origin-gesture="ignore"
:disabled="!origin.canGoBack.value"
@click="backWithButton"
>
{{ vertical ? "↓ Previous" : "← Previous" }}
</button>
<div class="nested-slide__dots" aria-label="Slide position">
<span
v-for="dot in total"
:key="dot"
:class="{ active: dot === index + 1 }"
/>
</div>
<button
type="button"
data-origin-gesture="ignore"
:disabled="!next"
@click="nextWithButton"
>
{{ vertical ? "Next ↑" : "Next →" }}
</button>
</footer>
</OriginGestureSurface>
</template>

View File

@@ -0,0 +1,275 @@
<script setup lang="ts">
import {
OriginGesture,
OriginScene,
back,
createOriginScene,
gesture,
originView,
useOrigin,
} from "@native-vue-router/core-v2";
import { useDemoInstance } from "../lab-state";
import { unfocusPortal } from "../motions";
import NestedSceneSlide from "./NestedSceneSlide.vue";
type NestedSceneKind = "cooperative" | "vertical" | "conflict";
interface SlideCopy {
eyebrow: string;
title: string;
description: string;
accent: string;
}
const slideSets: Record<NestedSceneKind, readonly SlideCopy[]> = {
cooperative: [
{
eyebrow: "Cooperative carousel",
title: "A scene inside a page",
description:
"Swipe left away from the glowing pass-through rail. The target mounts only inside this clipped carousel.",
accent: "#76dcff",
},
{
eyebrow: "Retained nested history",
title: "Slide one is still mounted",
description:
"Increment local state, move again, then return. The same nested Vue instances are revealed.",
accent: "#a78bfa",
},
{
eyebrow: "Independent operation graph",
title: "Three carousel instances",
description:
"This history belongs to the carousel scene. The outer page remains one mounted application entry.",
accent: "#65f2b1",
},
],
vertical: [
{
eyebrow: "Vertical nested deck",
title: "Pull upward inside this card",
description:
"The deck uses vertical progress, rotation, and a shared sideways frame arc.",
accent: "#ffbd70",
},
{
eyebrow: "Orthogonal choreography",
title: "The page still owns its left rail",
description:
"Pull down to reveal the retained card, or begin on its pass-through rail and swipe right to leave the lab.",
accent: "#ff7f9f",
},
{
eyebrow: "Nested composition",
title: "Frames remain local",
description:
"Transforms compose exactly like the full-screen scene, but measurements come from this smaller viewport.",
accent: "#ffe079",
},
],
conflict: [
{
eyebrow: "Intentional conflict",
title: "This child claims every pointer-down",
description:
"Try the page's rightward back gesture over this card. The child sees it first, even though nested back is unavailable.",
accent: "#ff5f78",
},
{
eyebrow: "Child-owned back",
title: "Now rightward swipe works here",
description:
"After moving forward, the exact same gesture can resolve nested back, producing the cube reversal.",
accent: "#ff8d4d",
},
],
};
function nestedView(kind: NestedSceneKind, index: number) {
const slides = slideSets[kind];
const copy = slides[index]!;
return originView(
NestedSceneSlide,
{
kind,
index,
total: slides.length,
...copy,
next:
index + 1 < slides.length
? () => nestedView(kind, index + 1)
: undefined,
},
{
key: `${kind}-slide-${index + 1}`,
name: `${kind} slide ${index + 1}`,
},
);
}
function nestedScene(kind: NestedSceneKind) {
return createOriginScene({ initial: nestedView(kind, 0) });
}
interface NestedCase {
kind: NestedSceneKind;
status: "supported" | "supported-with-policy" | "known-conflict";
title: string;
summary: string;
expected: string;
issue: string;
scene: ReturnType<typeof createOriginScene>;
}
const cases: readonly NestedCase[] = [
{
kind: "cooperative",
status: "supported-with-policy",
title: "Reserved-edge carousel",
summary:
"Horizontal nested history with a start predicate that leaves a clamp(22px, 12%, 52px) rail for its parent.",
expected:
"Swipe left or right beyond the rail; swipe right from the card's rail to trigger parent back.",
issue:
"The parent/child agreement is spatial policy written by the developer, not automatic arbitration.",
scene: nestedScene("cooperative"),
},
{
kind: "vertical",
status: "supported",
title: "Vertical component deck",
summary:
"A second nested scene uses upward/downward gestures and an entirely different frame animation.",
expected:
"Swipe up for the next card, down for retained back, and swipe right from its rail for parent back.",
issue:
"Its touch-action prevents page scrolling while a touch begins over the deck, which is appropriate here but must be intentional.",
scene: nestedScene("vertical"),
},
{
kind: "conflict",
status: "known-conflict",
title: "Greedy child recognizer",
summary:
"The cube carousel deliberately uses `.to.left()` and `.to.right()` without a `.from` constraint.",
expected: "Nested forward/back works and remains clipped to the card.",
issue:
"On slide one, parent back fails everywhere over this card: the child stops pointer-down propagation before `.navigate()` returns null.",
scene: nestedScene("conflict"),
},
];
const origin = useOrigin();
const identity = useDemoInstance("Nested scenes");
const pageBackGesture = gesture.to
.right()
.complete(
({ distance, bounds, progress, velocity }) =>
distance >= bounds.width * 0.08 || progress >= 0.22 || velocity >= 0.65,
)
.navigate((context) => (context.canGoBack ? back() : null))
.animate(unfocusPortal);
function backWithButton() {
if (origin.context.value.canGoBack) void origin.perform(back(unfocusPortal));
}
</script>
<template>
<OriginGesture class="lab-page nested-scenes-view" :gesture="pageBackGesture">
<button
class="view-back"
type="button"
data-origin-gesture="ignore"
@click="backWithButton"
>
Labs
</button>
<header class="nested-scenes-header">
<div>
<div class="eyebrow">Lab 07 · Nested origin scenes</div>
<h1>Components can own smaller worlds.</h1>
<p>
Each card below contains a completely independent
<code>OriginScene</code>. Its views, history, gestures, measurements,
and animations stay inside that card while this page remains the outer
origin.
</p>
</div>
<div class="nested-page-instance">
<span>Outer page instance</span>
<strong>{{ identity.instance }}</strong>
<small>rightward 8% page back</small>
</div>
</header>
<main class="nested-case-grid">
<section
v-for="item in cases"
:key="item.kind"
class="nested-case"
:class="`nested-case--${item.kind}`"
>
<header>
<span class="nested-case__status" :class="item.status">
{{ item.status.replaceAll("-", " ") }}
</span>
<h2>{{ item.title }}</h2>
<p>{{ item.summary }}</p>
</header>
<div class="nested-stage-shell">
<OriginScene class="nested-stage" :scene="item.scene" />
<div class="nested-stage-edge" aria-hidden="true">
{{
item.kind === "conflict"
? "parent blocked"
: "parent pass-through"
}}
</div>
</div>
<div class="nested-case__diagnostics">
<span>
{{ item.scene.nodes.value.length }} nested node{{
item.scene.nodes.value.length === 1 ? "" : "s"
}}
</span>
<span>
{{ item.scene.operations.value.length }} live edge{{
item.scene.operations.value.length === 1 ? "" : "s"
}}
</span>
</div>
<dl>
<div>
<dt>Expected</dt>
<dd>{{ item.expected }}</dd>
</div>
<div>
<dt>
{{ item.kind === "conflict" ? "Predicted failure" : "Caveat" }}
</dt>
<dd>{{ item.issue }}</dd>
</div>
</dl>
</section>
</main>
<footer class="nested-scenes-footer">
<strong>Ownership today is decided at pointer-down.</strong>
<span>
A future gesture arena could delay that decision and let a parent claim
the sequence when a child declines navigation.
</span>
</footer>
<div class="nested-page-gesture">
Parent gesture: swipe right outside a child, or from a cooperative rail
</div>
</OriginGesture>
</template>

View File

@@ -0,0 +1,114 @@
<script setup lang="ts">
import { computed, getCurrentInstance, ref, type Component } from "vue";
import {
above,
back,
gesture,
OriginGestureSurface,
originView,
slideRight,
useOrigin,
type OriginContext,
} from "@native-vue-router/core-v2";
import InstanceCard from "../components/InstanceCard.vue";
import { demoPhotos, type DemoPhoto } from "../gallery-data";
import { useDemoInstance } from "../lab-state";
import { arcSlide, coverDown } from "../motions";
const props = defineProps<{ photo: DemoPhoto; index: number }>();
const origin = useOrigin();
const identity = useDemoInstance(`Photo ${props.index + 1}`);
const liked = ref(false);
const ownComponent = getCurrentInstance()?.type as Component;
const nextPhoto = computed(
() => demoPhotos[(props.index + 1) % demoPhotos.length]!,
);
/*
* The target uses the same SFC definition with different props. It is still a
* fresh scene node and Vue instance, which makes recipe identity visible.
*/
const nextPhotoView = () =>
originView(
ownComponent,
{
photo: nextPhoto.value,
index: (props.index + 1) % demoPhotos.length,
},
{ key: `photo-${nextPhoto.value.id}`, name: nextPhoto.value.title },
);
const goNext = () => above(nextPhotoView(), arcSlide);
const goBack = (context: OriginContext, vertical = false) =>
context.canGoBack ? back(vertical ? coverDown : slideRight) : null;
const nextGesture = gesture.to
.left()
.navigate(() => above(nextPhotoView()))
.animate(arcSlide);
const dismissGesture = gesture.to
.down()
.navigate((context) => (context.canGoBack ? back() : null))
.animate(coverDown);
const edgeBackGesture = gesture.from
.left("clamp(24px, 8%, 64px)")
.to.right()
.navigate((context) => (context.canGoBack ? back() : null))
.animate(slideRight);
const gestures = [nextGesture, dismissGesture, edgeBackGesture] as const;
function close() {
const action = goBack(origin.context.value, true);
if (action) void origin.perform(action);
}
</script>
<template>
<OriginGestureSurface
as="article"
class="photo-detail"
:style="{ background: photo.gradient }"
:gestures="gestures"
>
<div class="photo-vignette" />
<header class="photo-toolbar">
<button type="button" data-origin-gesture="ignore" @click="close">
Close
</button>
<button
type="button"
data-origin-gesture="ignore"
:aria-pressed="liked"
@click="liked = !liked"
>
{{ liked ? "♥ Liked" : "♡ Like" }}
</button>
</header>
<main class="photo-copy">
<div class="eyebrow">{{ photo.location }}</div>
<h1>{{ photo.title }}</h1>
<p>{{ photo.description }}</p>
<InstanceCard
label="Photo instance"
:instance="identity.instance"
:status="identity.status.value"
/>
<button
class="primary-action"
type="button"
data-origin-gesture="ignore"
@click="origin.perform(goNext())"
>
Next: {{ nextPhoto.title }}
</button>
<div class="gesture-map">
<span> next photo</span>
<span> retained previous instance</span>
<span>left edge retained previous instance</span>
</div>
</main>
<div class="edge-marker edge-marker--left">back edge</div>
</OriginGestureSurface>
</template>

View File

@@ -0,0 +1,105 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from "vue";
import {
OriginGesture,
back,
gesture,
useOrigin,
type OriginContext,
} from "@native-vue-router/core-v2";
import InstanceCard from "../components/InstanceCard.vue";
import { useDemoInstance } from "../lab-state";
import { coverDown } from "../motions";
const origin = useOrigin();
const identity = useDemoInstance("Player");
const playing = ref(false);
const position = ref(38);
const elapsed = computed(() => `1:${String(position.value).padStart(2, "0")}`);
const timer = window.setInterval(() => {
if (playing.value) position.value = (position.value + 1) % 60;
}, 1000);
onBeforeUnmount(() => window.clearInterval(timer));
const dismiss = (context: OriginContext) =>
context.canGoBack ? back(coverDown) : null;
const dismissGesture = gesture.to
.down()
.navigate((context) => (context.canGoBack ? back() : null))
.animate(coverDown);
function close() {
const action = dismiss(origin.context.value);
if (action) void origin.perform(action);
}
</script>
<template>
<OriginGesture class="player-view" :gesture="dismissGesture">
<header class="player-header">
<button type="button" data-origin-gesture="ignore" @click="close">
</button>
<span>Now playing</span>
<button type="button" data-origin-gesture="ignore"></button>
</header>
<div class="album-art">
<span class="album-ring album-ring--one" />
<span class="album-ring album-ring--two" />
<span class="album-center">O</span>
</div>
<section class="track-copy">
<div>
<h1>Independent Frames</h1>
<p>The Scene Graph</p>
</div>
<button
type="button"
data-origin-gesture="ignore"
:aria-pressed="playing"
@click="playing = !playing"
>
{{ playing ? "♥" : "♡" }}
</button>
</section>
<label class="timeline">
<input
v-model="position"
data-origin-gesture="ignore"
type="range"
min="0"
max="59"
/>
<span>{{ elapsed }}</span>
<span>3:42</span>
</label>
<div class="player-controls">
<button type="button" data-origin-gesture="ignore"></button>
<button
class="play-button"
type="button"
data-origin-gesture="ignore"
@click="playing = !playing"
>
{{ playing ? "Ⅱ" : "▶" }}
</button>
<button type="button" data-origin-gesture="ignore"></button>
</div>
<InstanceCard
label="Player instance"
:instance="identity.instance"
:status="identity.status.value"
/>
<p class="policy-note">
Swipe down anywhere to dismiss. Horizontal back is intentionally absent,
like a native full-screen player.
</p>
<div class="drag-handle" />
</OriginGesture>
</template>

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
import {
above,
back,
gesture,
OriginGestureSurface,
originView,
slideLeft,
slideRight,
} from "@native-vue-router/core-v2";
import InstanceCard from "../components/InstanceCard.vue";
import { useDemoInstance } from "../lab-state";
import ThirdView from "./ThirdView.vue";
defineProps<{ openedAt?: string; createdBy?: string }>();
const identity = useDemoInstance("Y");
const thirdView = () =>
originView(
ThirdView,
{ createdBy: identity.instance },
{ key: "third", name: "Third" },
);
const forwardGesture = gesture.to
.left()
.navigate(() => above(thirdView()))
.animate(slideLeft);
const backGesture = gesture
// .from.left("clamp(24px, 8%, 64px)")
// .from.left("20%")
.to.right()
.navigate((context) => (context.canGoBack ? back() : null))
.animate(slideRight);
const gestures = [forwardGesture, backGesture] as const;
</script>
<template>
<OriginGestureSurface
as="main"
class="lab-page chain-view view-two"
:gestures="gestures"
>
<div class="eyebrow">Chain stress test · Origin Y</div>
<h1>Y can move while X is still moving it.</h1>
<p>
XY supplies Y's inherited coordinate frame. This YZ gesture adds a
second transform layer inside that frame.
</p>
<div class="metric-grid">
<div>
<span>Created by X</span>
<strong>{{ createdBy }}</strong>
</div>
<div>
<span>Created at</span>
<strong>{{ openedAt ?? "unknown" }}</strong>
</div>
</div>
<InstanceCard
label="Y Vue instance"
:instance="identity.instance"
:status="identity.status.value"
/>
<div class="gesture-hint">Swipe left again <span></span></div>
<div class="edge-marker edge-marker--left">back edge</div>
</OriginGestureSurface>
</template>

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import {
above,
back,
gesture,
OriginGestureSurface,
originView,
slideRight,
} from "@native-vue-router/core-v2";
import InstanceCard from "../components/InstanceCard.vue";
import { useDemoInstance } from "../lab-state";
import { arcSlide } from "../motions";
import FourthView from "./FourthView.vue";
defineProps<{ createdBy?: string }>();
const identity = useDemoInstance("Z");
const fourthView = () =>
originView(
FourthView,
{ chain: `X → Y → ${identity.instance}` },
{ key: "fourth", name: "Omega" },
);
const forwardGesture = gesture.to
.left()
.navigate(() => above(fourthView()))
.animate(arcSlide);
const backGesture = gesture
// .from.left("clamp(24px, 8%, 64px)")
// .from.left("20%")
.to.right()
.navigate((context) => (context.canGoBack ? back() : null))
.animate(slideRight);
const gestures = [forwardGesture, backGesture] as const;
</script>
<template>
<OriginGestureSurface
as="main"
class="lab-page chain-view view-three"
:gestures="gestures"
>
<div class="eyebrow">Chain stress test · Origin Z</div>
<h1>Now add an arc while two frames may still be moving.</h1>
<p>
Z inherits XY and YZ motion, then originates its own frame contribution.
The shared frame lifts both Z and Ω through a small arc.
</p>
<div class="proof">
Created by Y instance <strong>{{ createdBy }}</strong>
</div>
<InstanceCard
label="Z Vue instance"
:instance="identity.instance"
:status="identity.status.value"
/>
<div class="gesture-hint">Swipe left for Ω <span></span></div>
<div class="edge-marker edge-marker--left">back edge</div>
</OriginGestureSurface>
</template>

View File

@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.app.json",
"compilerOptions": {
"tsBuildInfoFile": "../../node_modules/.tmp/origins-demo.tsbuildinfo",
"baseUrl": ".",
"paths": {
"@native-vue-router/core-v2": ["../../packages/core-v2/src/index.ts"]
}
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}

View File

@@ -0,0 +1,174 @@
import path from "node:path";
import vue from "@vitejs/plugin-vue";
import { defineConfig, type Plugin } from "vite";
import { VitePWA } from "vite-plugin-pwa";
/**
* Recover browsers that visited this hostname while Docker still served Vite's
* development mode. Its development worker can retain HTML that imports
* `/@vite/client` and `/src/main.ts`; a normal static fallback would return
* HTML for those module URLs and leave the visitor on a blank screen.
*
* This preview-only compatibility endpoint is requested only by that obsolete
* shell. It clears the old origin-local worker/cache state and writes the
* current production HTML into the document. The production application then
* registers the real `/sw.js` worker normally.
*/
function legacyDevelopmentWorkerMigration(): Plugin {
const migrationSource = `
void (async () => {
if ("serviceWorker" in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
await Promise.all(registrations.map((registration) => registration.unregister()));
}
if ("caches" in window) {
const names = await caches.keys();
await Promise.all(names.map((name) => caches.delete(name)));
}
const productionDocument = await fetch(
"/index.html?origin-pwa-migration=" + Date.now(),
{ cache: "no-store" },
);
const html = await productionDocument.text();
document.open();
document.write(html);
document.close();
})();
`;
return {
name: "origins-legacy-development-worker-migration",
configurePreviewServer(server) {
server.middlewares.use((request, response, next) => {
const pathname = new URL(request.url ?? "/", "http://origins.local")
.pathname;
if (pathname === "/@vite/client") {
response.statusCode = 200;
response.setHeader("Content-Type", "text/javascript; charset=utf-8");
response.setHeader("Cache-Control", "no-store");
response.end(
"// Compatibility stub for the retired Vite dev client.",
);
return;
}
if (pathname === "/src/main.ts") {
response.statusCode = 200;
response.setHeader("Content-Type", "text/javascript; charset=utf-8");
response.setHeader("Cache-Control", "no-store");
response.end(migrationSource);
return;
}
next();
});
},
};
}
export default defineConfig({
root: __dirname,
base: "./",
// Reuse the workspace's existing install icons rather than maintaining a
// second set that can silently drift from the main demo.
publicDir: path.resolve(__dirname, "../../public"),
plugins: [
vue(),
legacyDevelopmentWorkerMigration(),
VitePWA({
registerType: "autoUpdate",
devOptions: {
// The hosted Docker service runs Vite's development server, so its
// public HTTPS URL also needs a real manifest and service worker.
enabled: true,
navigateFallback: "index.html",
suppressWarnings: true,
},
includeAssets: [
"favicon.svg",
"app-icon.svg",
"apple-touch-icon.png",
"pwa-192.png",
"pwa-512.png",
],
manifest: {
id: "/",
name: "Routeless Origins Lab",
short_name: "Origins Lab",
description:
"Seven interactive labs for a routeless Vue animation and gesture engine.",
theme_color: "#080b12",
background_color: "#080b12",
display: "standalone",
scope: "/",
start_url: "/",
orientation: "any",
icons: [
{
src: "/pwa-192.png",
sizes: "192x192",
type: "image/png",
purpose: "any",
},
{
src: "/pwa-512.png",
sizes: "512x512",
type: "image/png",
purpose: "any",
},
{
src: "/pwa-512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
{
src: "/app-icon.svg",
sizes: "any",
type: "image/svg+xml",
purpose: "any",
},
],
},
workbox: {
cleanupOutdatedCaches: true,
clientsClaim: true,
skipWaiting: true,
navigateFallback: "/index.html",
globPatterns: ["**/*.{js,css,html,svg,png,woff2}"],
},
}),
],
server: {
// The development container is reached through this Traefik hostname.
// Keeping the allow-list explicit preserves Vite's DNS-rebinding defense.
allowedHosts: ["v2.demo.native-router.harvmaster.com"],
},
preview: {
allowedHosts: ["v2.demo.native-router.harvmaster.com"],
},
resolve: {
alias: [
{
find: /^@native-vue-router\/core-v2\/style\.css$/,
replacement: path.resolve(
__dirname,
"../../packages/core-v2/src/style.css",
),
},
{
find: /^@native-vue-router\/core-v2$/,
replacement: path.resolve(
__dirname,
"../../packages/core-v2/src/index.ts",
),
},
],
},
build: {
outDir: path.resolve(__dirname, "dist"),
emptyOutDir: true,
},
});

105
docs/routeless-origins.md Normal file
View File

@@ -0,0 +1,105 @@
# Routeless origins architecture
The v2 experiment lives in `packages/core-v2` and its physical test application
lives in `apps/origins-demo`.
## State model
The scene contains:
- stable, flat Vue component nodes;
- linked mounted-instance history entries;
- temporary directed operation edges.
It does not contain an active route or current view.
For overlapping operations:
```text
Nodes: X, Y, Z
Edges: X → Y
Y → Z
```
Each edge records its own progress, velocity, outcome, choreography, and
source/target keys.
## Coordinate composition
Suppose A is X→Y and B is Y→Z:
```text
visual(X) = A.source
visual(Y) = A.target × B.source
visual(Z) = A.target × B.target
```
An optional frame effect is included on both sides of an edge:
```text
visual(Y) = A.frame × A.target × B.frame × B.source
visual(Z) = A.frame × A.target × B.frame × B.target
```
The implementation emits these operations as one combined transform on each
flat host. This has the visual semantics of nested coordinate frames without
reparenting Vue component VNodes.
## Completion
Committing a forward X→Y:
1. Keeps X mounted but marks it parked, inert, and visually hidden.
2. Places Y in X's former visual graph position.
3. Removes the X→Y edge.
4. Leaves any Y→Z edge and all mounted descendants intact.
Committing Y→Z before X→Y is also valid. Z replaces Y as the target of the
still-running X edge, after which X→Y effectively becomes X→Z. The unit suite
covers both completion orders.
Committing back from Y to X reveals the existing X node and removes Y. It does
not construct X again from its recipe. Cancelling a forward edge restores its
source and removes the newly created target branch; cancelling a back edge
re-parks its retained target.
## Interaction
Gesture recognition is declared within each component through
`OriginGesture` or `useOriginGesture()`. The injected scene-node key determines
the origin. Recognition never asks a coordinator which view is active.
The immutable `gesture` builder separates optional pointer-down policy
(`.from`), movement recognition (`.to`), release policy (`.complete`),
navigation intent (`.navigate`), and visual choreography (`.animate`). A chain
that begins at `.to` is valid and admits pointer-down anywhere on its host.
At pointer release, the operation decides synchronously whether it will commit
or cancel. Its spring may continue afterward. A retained target can therefore
originate another routine while the preceding spring is still visible.
### Nested scenes
An `OriginScene` may be rendered inside a view owned by another scene. The
nearest injected node scope makes carousel or deck gestures operate on the
nested scene, with local measurements and retained history.
Gesture ownership between nested scenes is currently selected at pointer-down.
An eligible child stops propagation even if its later navigation factory
declines. Parent fallback therefore requires the child to reserve a
non-matching `.from` region; automatic delayed arbitration remains future
gesture-arena work.
## Retained history
Each pushed node stores the key of its mounted previous entry. The chain is
local to that origin context rather than a URL:
```text
X (parked) ← Y (parked) ← Z (visible)
```
Back targets the previous node key directly. A node is unmounted only when a
committed back operation pops it, a forward operation is cancelled, or the
whole scene is destroyed. Because the same DOM survives parking, nested scroll
positions and component-local state survive without `<KeepAlive>`.

31
package-lock.json generated
View File

@@ -70,6 +70,21 @@
"name": "@native-vue-router/demo-electron",
"version": "0.1.0"
},
"apps/origins-demo": {
"name": "@native-vue-router/origins-demo",
"version": "0.1.0",
"dependencies": {
"@native-vue-router/core-v2": "0.1.0-experimental.0",
"vue": "^3.5.39"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.7",
"typescript": "~6.0.2",
"vite": "^8.1.1",
"vite-plugin-pwa": "^1.1.0",
"vue-tsc": "^3.3.5"
}
},
"node_modules/@apideck/better-ajv-errors": {
"version": "0.3.7",
"resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.7.tgz",
@@ -2547,6 +2562,10 @@
"resolved": "packages/core",
"link": true
},
"node_modules/@native-vue-router/core-v2": {
"resolved": "packages/core-v2",
"link": true
},
"node_modules/@native-vue-router/demo": {
"resolved": "apps/demo",
"link": true
@@ -2563,6 +2582,10 @@
"resolved": "packages/electron",
"link": true
},
"node_modules/@native-vue-router/origins-demo": {
"resolved": "apps/origins-demo",
"link": true
},
"node_modules/@native-vue-router/preset-native": {
"resolved": "packages/preset-native",
"link": true
@@ -9297,6 +9320,14 @@
"vue-router": "^5.0.0"
}
},
"packages/core-v2": {
"name": "@native-vue-router/core-v2",
"version": "0.1.0-experimental.0",
"license": "MIT",
"peerDependencies": {
"vue": "^3.5.0"
}
},
"packages/electron": {
"name": "@native-vue-router/electron",
"version": "0.1.0",

View File

@@ -10,7 +10,10 @@
"scripts": {
"dev": "vite --host",
"build": "npm run build:packages && vue-tsc -b && vite build",
"build:packages": "npm run build --workspace @native-vue-router/core --workspace @native-vue-router/preset-native --workspace @native-vue-router/capacitor --workspace @native-vue-router/electron --if-present",
"build:packages": "npm run build --workspace @native-vue-router/core --workspace @native-vue-router/core-v2 --workspace @native-vue-router/preset-native --workspace @native-vue-router/capacitor --workspace @native-vue-router/electron --if-present",
"build:v2-demo": "npm run build --workspace @native-vue-router/origins-demo",
"dev:v2": "npm run dev --workspace @native-vue-router/origins-demo",
"serve:v2": "npm run build:v2-demo && npm run preview --workspace @native-vue-router/origins-demo",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",

720
packages/core-v2/API.md Normal file
View File

@@ -0,0 +1,720 @@
# 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)
## 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);
```
`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);
```
Interactive form controls are ignored automatically. Add
`data-origin-gesture="ignore"` to any other element or ancestor that should not
begin a 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` | — | Preferred 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>
```
For compatibility, the component also accepts the legacy mutually exclusive
set of `direction`, `edge`, `threshold`, and `action` props.
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 with `useOriginGesture()`, forwards every pointer event to every
binding, and derives the least-permissive shared `touch-action`:
- 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.
## 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"`.
### `originAction(target, choreography, options?)`
Constructs a complete low-level `OriginAction`. Prefer `forward()` 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 |
| `"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 |
| `.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 |
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.
### `useOriginGesture(definition)`
Creates one primary-pointer, single-axis recognizer owned by the component that
calls it.
```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;
}
```
Apply all handlers to the same element. The returned style sets dimensions and
`touch-action` so native scrolling remains available on the cross-axis.
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`.
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.
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.
## 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.
`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, a target commits
when either:
- `progress >= commitThreshold`, default `0.36`; or
- `progress >= 0.06` and `velocity >= commitVelocity`, default `0.9`.
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, 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`
| 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 |
| `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()`, `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.
### `MaybeOriginAction`
```ts
OriginAction | null | undefined | Promise<OriginAction | null | undefined>;
```
### `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 |
Rect values are viewport CSS pixels.
### Diagnostic types
- `OriginSceneNode`: mounted identity, retained previous key, state, recipe,
history, and incoming edge.
- `OriginSceneNodeState`: `active`, `transitioning`, 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
- `useOrigin()` and `useOriginGesture()` must run inside a component mounted by
`OriginScene`.
- An origin can own only one outgoing operation at a time.
- Parked history entries cannot originate operations until back reveals them.
- 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.

234
packages/core-v2/README.md Normal file
View File

@@ -0,0 +1,234 @@
# 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](./API.md).
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:
```sh
npm run dev:v2
```
Open the printed URL to explore seven 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.
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:
```ts
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:
```vue
<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:
```vue
<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:
```vue
<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:
```ts
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.
## Custom choreography
A choreography returns independent effects for its source, target, and their
shared frame:
```ts
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:
```text
Y transform = (X→Y target) × (Y→Z source)
Z transform = (X→Y target) × (Y→Z target)
```
## 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.
## 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.

View File

@@ -0,0 +1,27 @@
{
"name": "@native-vue-router/core-v2",
"version": "0.1.0-experimental.0",
"type": "module",
"license": "MIT",
"files": [
"dist",
"API.md",
"README.md"
],
"sideEffects": [
"./dist/style.css"
],
"scripts": {
"build": "vite build --config vite.config.ts && vue-tsc -p tsconfig.json --emitDeclarationOnly"
},
"peerDependencies": {
"vue": "^3.5.0"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./style.css": "./dist/style.css"
}
}

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import { computed } from "vue";
import { useOriginGesture } from "../gesture";
import type { OriginGestureProps } from "../types";
defineOptions({ name: "OriginGesture", inheritAttrs: false });
const props = withDefaults(defineProps<OriginGestureProps>(), {
as: "div",
});
/*
* 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.
*/
const gesture = useOriginGesture(
props.gesture ?? {
direction: props.direction,
edge: props.edge,
threshold: props.threshold,
action: (context) => props.action?.(context),
},
);
const touchStyle = computed(() => gesture.style);
</script>
<template>
<component
:is="as"
v-bind="$attrs"
class="nvo-gesture"
:style="touchStyle"
@pointerdown="gesture.onPointerdown"
@pointermove="gesture.onPointermove"
@pointerup="gesture.onPointerup"
@pointercancel="gesture.onPointercancel"
>
<slot />
</component>
</template>

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
import { computed } from "vue";
import { useOriginGesture } from "../gesture";
import type { OriginGestureSurfaceProps } from "../types";
defineOptions({ name: "OriginGestureSurface", inheritAttrs: false });
const props = withDefaults(defineProps<OriginGestureSurfaceProps>(), {
as: "div",
});
/*
* All interaction policy belongs to the component that built the definitions.
* 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(() => {
const horizontal = props.gestures.some(
({ direction }) => direction === "left" || direction === "right",
);
const vertical = props.gestures.some(
({ direction }) => direction === "up" || direction === "down",
);
return {
width: "100%",
height: "100%",
touchAction:
horizontal && vertical
? "none"
: horizontal
? "pan-y"
: vertical
? "pan-x"
: "auto",
} as const;
});
function pointerDown(event: PointerEvent) {
for (const binding of bindings) binding.onPointerdown(event);
}
function pointerMove(event: PointerEvent) {
for (const binding of bindings) binding.onPointermove(event);
}
function pointerUp(event: PointerEvent) {
for (const binding of bindings) binding.onPointerup(event);
}
function pointerCancel() {
for (const binding of bindings) binding.onPointercancel();
}
</script>
<template>
<component
:is="as"
v-bind="$attrs"
class="nvo-gesture"
:style="[$attrs.style, surfaceStyle]"
@pointerdown="pointerDown"
@pointermove="pointerMove"
@pointerup="pointerUp"
@pointercancel="pointerCancel"
>
<slot />
</component>
</template>

View File

@@ -0,0 +1,52 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, provide, ref, watchEffect } from "vue";
import type { OriginScene, OriginSceneNode } from "../types";
import { originNodeScopeKey } from "../lifecycle";
defineOptions({ name: "OriginNodeHost" });
const props = defineProps<{
scene: OriginScene;
node: OriginSceneNode;
}>();
/*
* This host is the stable physical home of the view component. It is keyed by
* the scene node in OriginScene and never nested under another view. Only its
* composed CSS style changes while operation edges are created and collapsed.
*/
const host = ref<HTMLElement | null>(null);
provide(originNodeScopeKey, {
scene: props.scene,
nodeKey: props.node.key,
});
watchEffect(() => {
props.scene.registerElement(props.node.key, host.value);
});
onBeforeUnmount(() => props.scene.registerElement(props.node.key, null));
const style = computed(() => props.scene.styleForNode(props.node.key));
const interactive = computed(() =>
props.scene.isNodeInteractive(props.node.key),
);
</script>
<template>
<section
ref="host"
class="nvo-node"
:style="style"
:data-origin-node="node.key"
:data-origin-view="node.view.name ?? node.view.key"
:data-origin-state="node.state"
:aria-hidden="interactive ? undefined : 'true'"
:inert="interactive ? undefined : true"
>
<!--
Vue owns the component lifecycle normally. Adding another origin merely
adds effect layers to this host; it does not replace this component VNode.
-->
<component :is="node.view.component" v-bind="node.view.props" />
</section>
</template>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, watchEffect } from "vue";
import type { OriginSceneProps } from "../types";
import OriginNodeHost from "./OriginNodeHost.vue";
defineOptions({ name: "OriginScene" });
const props = defineProps<OriginSceneProps>();
const root = ref<HTMLElement | null>(null);
watchEffect(() => props.scene.registerContainer(root.value));
onBeforeUnmount(() => props.scene.registerContainer(null));
</script>
<template>
<!--
All component hosts are siblings. The operation graph is intentionally not
mirrored as DOM ancestry because promoting Y after XY must not remount Y.
-->
<main
ref="root"
class="nvo-scene"
:style="{
position: 'relative',
width: '100%',
height: '100%',
overflow: 'hidden',
isolation: 'isolate',
}"
>
<OriginNodeHost
v-for="node in scene.nodes.value"
:key="node.key"
:scene="scene"
:node="node"
/>
</main>
</template>

View File

@@ -0,0 +1,248 @@
import { createApp, defineComponent, h, nextTick, type Component } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import OriginGestureSurface from "./components/OriginGestureSurface.vue";
import OriginScene from "./components/OriginScene.vue";
import { gesture, useOriginGesture } from "./gesture";
import { back, defineOriginChoreography, forward } from "./motion";
import { createOriginScene, originView } from "./scene";
import type {
OriginGestureBinding,
OriginGestureCompletionContext,
} from "./types";
const mountedApps: Array<ReturnType<typeof createApp>> = [];
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
const testMotion = defineOriginChoreography({
name: "gesture-test",
effects: ({ progress }) => ({
source: { opacity: 1 - progress },
target: { opacity: progress },
}),
});
function component(name: string): Component {
return defineComponent({
name,
render: () => h("div", name),
});
}
function pointer(
type: string,
init: Pick<PointerEventInit, "clientX" | "clientY">,
) {
return new PointerEvent(type, {
bubbles: true,
cancelable: true,
button: 0,
isPrimary: true,
pointerId: 7,
...init,
});
}
async function flushAsyncHandlers() {
await Promise.resolve();
await nextTick();
await Promise.resolve();
await nextTick();
}
describe("gesture builder", () => {
it("installs multiple page-owned definitions on a policy-neutral surface", async () => {
const horizontal = gesture.to
.left()
.navigate(() => null)
.animate(testMotion);
const vertical = gesture.from
.top("12%")
.to.down()
.navigate(() => null)
.animate(testMotion);
const Initial = defineComponent({
name: "SurfaceInitial",
render: () =>
h(
OriginGestureSurface,
{
as: "section",
id: "multi-gesture-surface",
gestures: [horizontal, vertical],
},
() => "surface",
),
});
const scene = createOriginScene({
initial: originView(Initial, undefined, { key: "surface-initial" }),
});
const root = document.createElement("div");
document.body.append(root);
const app = createApp({ render: () => h(OriginScene, { scene }) });
mountedApps.push(app);
app.mount(root);
await nextTick();
const surface = root.querySelector("#multi-gesture-surface") as HTMLElement;
expect(surface.tagName).toBe("SECTION");
expect(surface.classList.contains("nvo-gesture")).toBe(true);
expect(surface.style.touchAction).toBe("none");
expect(surface.style.width).toBe("100%");
expect(surface.style.height).toBe("100%");
});
it("keeps builder navigation intents separate from complete actions", () => {
const target = originView(component("Target"));
expect(forward(target)).toEqual({
target,
placement: "above",
history: "push",
});
expect(back()).toEqual({
placement: "under",
history: "back",
});
expect(forward(target, testMotion)).toMatchObject({
target,
choreography: testMotion,
history: "push",
});
expect(back(testMotion)).toMatchObject({
choreography: testMotion,
history: "back",
});
});
it("treats a chain beginning at .to as an immutable anywhere gesture", () => {
const definition = gesture.to
.right({ threshold: 12 })
.navigate(() => back())
.animate(testMotion);
expect(definition).toMatchObject({
kind: "origin-gesture-definition",
start: { kind: "anywhere" },
direction: "right",
recognition: { threshold: 12 },
choreography: testMotion,
});
expect(Object.isFrozen(definition)).toBe(true);
expect(Object.isFrozen(definition.recognition)).toBe(true);
});
it("keeps start predicates independent from movement direction", () => {
const predicate = vi.fn(() => true);
const complete = vi.fn(() => true);
const definition = gesture.from
.when(predicate)
.to.down({ axisDominance: 1.4 })
.complete(complete)
.navigate(() => forward(originView(component("Dialog"))))
.animate(testMotion);
expect(definition.start).toEqual({ kind: "when", predicate });
expect(definition.direction).toBe("down");
expect(definition.recognition.axisDominance).toBe(1.4);
expect(definition.completion).toBe(complete);
});
it("recognizes .to.right anywhere and lets .complete override release", async () => {
vi.stubGlobal(
"matchMedia",
vi.fn(() => ({ matches: true }) as MediaQueryList),
);
const Target = component("Target");
let binding: OriginGestureBinding | undefined;
let completion: OriginGestureCompletionContext | undefined;
const definition = gesture.to
.right()
.complete((context) => {
completion = context;
return false;
})
.navigate(() => forward(originView(Target, undefined, { key: "target" })))
.animate(testMotion);
const Initial = defineComponent({
name: "Initial",
setup() {
binding = useOriginGesture(definition);
return () =>
h(
"div",
{
id: "gesture-host",
style: binding!.style,
onPointerdown: binding!.onPointerdown,
onPointermove: binding!.onPointermove,
onPointerup: binding!.onPointerup,
onPointercancel: binding!.onPointercancel,
},
"Initial",
);
},
});
const scene = createOriginScene({
initial: originView(Initial, undefined, { key: "initial" }),
});
const root = document.createElement("div");
document.body.append(root);
const app = createApp({ render: () => h(OriginScene, { scene }) });
mountedApps.push(app);
app.mount(root);
await nextTick();
const host = root.querySelector("#gesture-host") as HTMLElement;
Object.defineProperties(host, {
clientWidth: { configurable: true, value: 200 },
clientHeight: { configurable: true, value: 400 },
});
host.getBoundingClientRect = () =>
({
top: 20,
left: 100,
right: 300,
bottom: 420,
width: 200,
height: 400,
x: 100,
y: 20,
toJSON: () => ({}),
}) as DOMRect;
// x=250 is nowhere near the left edge. With no `.from`, it is eligible.
host.dispatchEvent(pointer("pointerdown", { clientX: 250, clientY: 100 }));
host.dispatchEvent(pointer("pointermove", { clientX: 330, clientY: 102 }));
await flushAsyncHandlers();
expect(scene.operations.value).toHaveLength(1);
host.dispatchEvent(pointer("pointerup", { clientX: 350, clientY: 102 }));
await flushAsyncHandlers();
expect(completion).toMatchObject({
direction: "right",
progress: 0.5,
distance: 100,
crossDistance: 2,
});
expect(completion?.start).toMatchObject({
clientX: 250,
localX: 150,
});
expect(completion?.current).toMatchObject({
clientX: 350,
localX: 250,
});
expect(scene.operations.value).toHaveLength(0);
expect(scene.nodes.value.map((node) => node.view.name)).toEqual([
"Initial",
]);
});
});

View File

@@ -0,0 +1,582 @@
import type {
MaybeOriginAction,
OriginAction,
OriginContext,
OriginGestureBinding,
OriginGestureBuilder,
OriginGestureCompletionContext,
OriginGestureCompletionPredicate,
OriginGestureDefinition,
OriginGestureDirection,
OriginGestureDirectionOptions,
OriginGestureDistance,
OriginGestureEdge,
OriginGestureFromBuilder,
OriginGestureFromSelection,
OriginGestureNavigationBuilder,
OriginGestureNavigationFactory,
OriginGestureOptions,
OriginGesturePoint,
OriginGestureStart,
OriginGestureStartContext,
OriginGestureStartPredicate,
OriginGestureToBuilder,
OriginOperationHandle,
OriginRect,
} from "./types";
import { useOrigin } from "./lifecycle";
function ignoreGestureTarget(target: EventTarget | null) {
return (
!(target instanceof Element) ||
Boolean(
target.closest(
'[data-origin-gesture="ignore"], input, textarea, select, option, [contenteditable="true"]',
),
)
);
}
function directedDistance(
direction: OriginGestureDirection,
dx: number,
dy: number,
) {
switch (direction) {
case "left":
return -dx;
case "right":
return dx;
case "up":
return -dy;
case "down":
return dy;
}
}
function rectOf(element: HTMLElement): OriginRect {
const rect = element.getBoundingClientRect();
return {
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
};
}
function pointOf(
event: Pick<PointerEvent, "clientX" | "clientY">,
bounds: OriginRect,
): OriginGesturePoint {
return {
clientX: event.clientX,
clientY: event.clientY,
localX: event.clientX - bounds.left,
localY: event.clientY - bounds.top,
};
}
/**
* Resolve an arbitrary CSS length against a box with the gesture host's size.
*
* A short-lived off-screen box lets the browser handle `rem`, viewport units,
* percentages, `calc()`, and `clamp()` consistently. This runs only during
* pointer-down for edge-constrained definitions.
*/
function resolveCssDistance(
distance: OriginGestureDistance,
axis: "horizontal" | "vertical",
host: HTMLElement,
bounds: OriginRect,
) {
if (typeof distance === "number")
return Number.isFinite(distance) ? Math.max(0, distance) : 0;
const document = host.ownerDocument;
if (!document.body) return Math.max(0, Number.parseFloat(distance) || 0);
const container = document.createElement("div");
const probe = document.createElement("div");
Object.assign(container.style, {
position: "fixed",
left: "-100000px",
top: "-100000px",
width: `${bounds.width}px`,
height: `${bounds.height}px`,
visibility: "hidden",
pointerEvents: "none",
contain: "strict",
});
Object.assign(probe.style, {
position: "absolute",
width: axis === "horizontal" ? distance : "0",
height: axis === "vertical" ? distance : "0",
});
container.append(probe);
document.body.append(container);
const resolved =
axis === "horizontal"
? probe.getBoundingClientRect().width
: probe.getBoundingClientRect().height;
container.remove();
return Number.isFinite(resolved) ? Math.max(0, resolved) : 0;
}
function matchesStart(
start: OriginGestureStart,
event: PointerEvent,
host: HTMLElement,
origin: OriginContext,
bounds: OriginRect,
) {
if (start.kind === "anywhere") return true;
const point = pointOf(event, bounds);
if (start.kind === "when") {
const context: OriginGestureStartContext = {
event,
origin,
host,
bounds,
point,
};
return start.predicate(context);
}
const horizontal = start.edge === "left" || start.edge === "right";
const distance = resolveCssDistance(
start.distance,
horizontal ? "horizontal" : "vertical",
host,
bounds,
);
switch (start.edge) {
case "left":
return point.localX >= 0 && point.localX <= distance;
case "right":
return (
point.localX <= bounds.width && bounds.width - point.localX <= distance
);
case "top":
return point.localY >= 0 && point.localY <= distance;
case "bottom":
return (
point.localY <= bounds.height &&
bounds.height - point.localY <= distance
);
}
}
function edgeStart(
edge: OriginGestureEdge,
distance: OriginGestureDistance,
): OriginGestureStart {
return Object.freeze({ kind: "edge", edge, distance });
}
function createNavigationBuilder(
start: OriginGestureStart,
direction: OriginGestureDirection,
recognition: Readonly<OriginGestureDirectionOptions>,
completion: OriginGestureCompletionPredicate | undefined,
navigation: OriginGestureNavigationFactory,
): OriginGestureNavigationBuilder {
return Object.freeze({
animate(choreography: OriginGestureDefinition["choreography"]) {
return Object.freeze({
kind: "origin-gesture-definition",
start,
direction,
recognition,
completion,
navigation,
choreography,
});
},
});
}
function createDirectedBuilder(
start: OriginGestureStart,
direction: OriginGestureDirection,
options: OriginGestureDirectionOptions = {},
) {
const recognition = Object.freeze({ ...options });
const navigate = (
navigation: OriginGestureNavigationFactory,
completion?: OriginGestureCompletionPredicate,
) =>
createNavigationBuilder(
start,
direction,
recognition,
completion,
navigation,
);
return Object.freeze({
complete(completion: OriginGestureCompletionPredicate) {
return Object.freeze({
navigate: (navigation: OriginGestureNavigationFactory) =>
navigate(navigation, completion),
});
},
navigate,
});
}
function createToBuilder(start: OriginGestureStart): OriginGestureToBuilder {
return Object.freeze({
left: (options?: OriginGestureDirectionOptions) =>
createDirectedBuilder(start, "left", options),
right: (options?: OriginGestureDirectionOptions) =>
createDirectedBuilder(start, "right", options),
up: (options?: OriginGestureDirectionOptions) =>
createDirectedBuilder(start, "up", options),
down: (options?: OriginGestureDirectionOptions) =>
createDirectedBuilder(start, "down", options),
});
}
function selectStart(start: OriginGestureStart): OriginGestureFromSelection {
return Object.freeze({ to: createToBuilder(Object.freeze(start)) });
}
const fromBuilder: OriginGestureFromBuilder = Object.freeze({
left: (distance: OriginGestureDistance) =>
selectStart(edgeStart("left", distance)),
right: (distance: OriginGestureDistance) =>
selectStart(edgeStart("right", distance)),
top: (distance: OriginGestureDistance) =>
selectStart(edgeStart("top", distance)),
bottom: (distance: OriginGestureDistance) =>
selectStart(edgeStart("bottom", distance)),
anywhere: () => selectStart({ kind: "anywhere" }),
when: (predicate: OriginGestureStartPredicate) =>
selectStart({ kind: "when", predicate }),
});
/**
* Root of the immutable gesture builder.
*
* `.from` is optional. Starting at `.to` admits pointer-down anywhere on the
* bound host, exactly like `.from.anywhere().to`.
*
* @example Anywhere-to-right back gesture
* ```ts
* const swipeBack = gesture
* .to.right()
* .navigate((context) => context.canGoBack ? back() : null)
* .animate(slideRight);
* ```
*
* @example Predicate-gated gesture with custom completion
* ```ts
* const openPanel = gesture
* .from.when(({ point, bounds }) => point.localX <= bounds.width * 0.08)
* .to.down()
* .complete(({ progress, velocity }) => progress > 0.5 || velocity > 1)
* .navigate(() => above(originView(PanelView)))
* .animate(dropPanel);
* ```
*/
export const gesture: OriginGestureBuilder = Object.freeze({
from: fromBuilder,
to: createToBuilder(Object.freeze({ kind: "anywhere" })),
});
function isDefinition(
value: OriginGestureOptions | OriginGestureDefinition,
): value is OriginGestureDefinition {
return "kind" in value && value.kind === "origin-gesture-definition";
}
interface RuntimeGesturePolicy {
readonly direction: OriginGestureDirection;
readonly start: OriginGestureStart;
readonly threshold: number;
readonly axisDominance: number;
readonly completion?: OriginGestureCompletionPredicate;
readonly action: (context: OriginContext) => MaybeOriginAction;
}
function legacyStart(options: OriginGestureOptions): OriginGestureStart {
if (options.edge === undefined) return { kind: "anywhere" };
switch (options.direction) {
case "left":
return edgeStart("right", options.edge);
case "right":
return edgeStart("left", options.edge);
case "up":
return edgeStart("bottom", options.edge);
case "down":
return edgeStart("top", options.edge);
}
}
function runtimePolicy(
options: OriginGestureOptions | OriginGestureDefinition,
): RuntimeGesturePolicy {
if (!isDefinition(options)) {
return {
direction: options.direction,
start: legacyStart(options),
threshold: options.threshold ?? 8,
axisDominance: 1.15,
action: options.action,
};
}
return {
direction: options.direction,
start: options.start,
threshold: options.recognition.threshold ?? 8,
axisDominance: options.recognition.axisDominance ?? 1.15,
completion: options.completion,
action: async (context) => {
const navigation = await options.navigation(context);
if (!navigation) return navigation;
const action: OriginAction = {
...navigation,
choreography: options.choreography,
};
return action;
},
};
}
/**
* Install a component-owned pointer recognizer.
*
* Recognition is local to the element receiving these handlers. There is no
* application-wide gesture table and no lookup of a currently active view.
*
* Builder definitions may independently describe their pointer-down region
* and movement direction. Omitting `.from` recognizes pointer-down across the
* whole element. The legacy options object remains supported; its `edge` is
* inferred from the opposite side of its movement direction.
*
* Interactive controls and anything inside
* `[data-origin-gesture="ignore"]` are ignored automatically. The recognizer
* preserves native scrolling on the cross-axis through its returned style.
*
* @param definition - An immutable builder result or legacy recognizer options.
* @returns Pointer handlers and required host styles.
* @throws If called outside a component rendered by `OriginScene`.
*
* @example Builder-defined backward gesture
* ```ts
* const swipeBack = useOriginGesture(
* gesture
* .from.left("32px")
* .to.right()
* .navigate((context) => context.canGoBack ? back() : null)
* .animate(slideRight),
* );
* ```
*/
export function useOriginGesture(
definition: OriginGestureDefinition | OriginGestureOptions,
): OriginGestureBinding {
const origin = useOrigin();
const policy = runtimePolicy(definition);
let pointerId = -1;
let element: HTMLElement | null = null;
let bounds: OriginRect | null = null;
let originContext: OriginContext | null = null;
let startPoint: OriginGesturePoint | null = null;
let startTime = 0;
let startX = 0;
let startY = 0;
let lastCoordinate = 0;
let lastTime = 0;
let captured = false;
let generation = 0;
let handlePromise: Promise<OriginOperationHandle | null> | null = null;
let bufferedProgress = 0;
let bufferedVelocity = 0;
let bufferedDistance = 0;
let bufferedCrossDistance = 0;
const horizontal =
policy.direction === "left" || policy.direction === "right";
function reset() {
pointerId = -1;
element = null;
bounds = null;
originContext = null;
startPoint = null;
captured = false;
handlePromise = null;
bufferedProgress = 0;
bufferedVelocity = 0;
bufferedDistance = 0;
bufferedCrossDistance = 0;
}
function onPointerdown(event: PointerEvent) {
const current = event.currentTarget;
if (
!event.isPrimary ||
event.button !== 0 ||
!(current instanceof HTMLElement) ||
ignoreGestureTarget(event.target)
)
return;
const nextBounds = rectOf(current);
const nextOriginContext = origin.context.value;
if (
!matchesStart(policy.start, event, current, nextOriginContext, nextBounds)
)
return;
// The component containing this declaration is the operation's origin.
event.stopPropagation();
generation += 1;
pointerId = event.pointerId;
element = current;
bounds = nextBounds;
originContext = nextOriginContext;
startPoint = pointOf(event, nextBounds);
startTime = event.timeStamp;
startX = event.clientX;
startY = event.clientY;
lastCoordinate = horizontal ? event.clientX : event.clientY;
lastTime = event.timeStamp;
captured = false;
handlePromise = null;
}
function updateMetrics(event: PointerEvent, release = false) {
if (!element) return;
const dx = event.clientX - startX;
const dy = event.clientY - startY;
bufferedDistance = directedDistance(policy.direction, dx, dy);
bufferedCrossDistance = horizontal ? Math.abs(dy) : Math.abs(dx);
const size = Math.max(
1,
horizontal ? element.clientWidth : element.clientHeight,
);
const coordinate = horizontal ? event.clientX : event.clientY;
const coordinateDelta =
policy.direction === "left" || policy.direction === "up"
? lastCoordinate - coordinate
: coordinate - lastCoordinate;
const rawElapsed = event.timeStamp - lastTime;
const elapsed = Math.max(8, rawElapsed);
bufferedProgress = Math.max(0, Math.min(1, bufferedDistance / size));
/*
* Pointer-up commonly repeats the final pointer-move coordinate. Preserve
* that move's flick velocity for a prompt release, but decay it when the
* pointer was held still long enough for the flick to have ended.
*/
if (!release || coordinateDelta !== 0 || rawElapsed > 80)
bufferedVelocity = (coordinateDelta * 1000) / (elapsed * size);
lastCoordinate = coordinate;
lastTime = event.timeStamp;
}
async function onPointermove(event: PointerEvent) {
if (event.pointerId !== pointerId || !element) return;
const dx = event.clientX - startX;
const dy = event.clientY - startY;
const distance = directedDistance(policy.direction, dx, dy);
const crossDistance = horizontal ? Math.abs(dy) : Math.abs(dx);
let metricsUpdated = false;
if (!captured) {
if (
distance < policy.threshold ||
distance < crossDistance * policy.axisDominance
)
return;
captured = true;
element.setPointerCapture?.(pointerId);
event.preventDefault();
updateMetrics(event);
metricsUpdated = true;
const recognitionGeneration = generation;
const action = await Promise.resolve(
policy.action(origin.context.value),
).catch(() => null);
// An asynchronous target resolver may finish after the pointer was
// released or cancelled. It must not create an orphan scene operation.
if (recognitionGeneration !== generation || event.pointerId !== pointerId)
return;
if (!action) return reset();
handlePromise = origin.begin(action).catch(() => null);
}
event.preventDefault();
if (!metricsUpdated) updateMetrics(event);
const pending = handlePromise;
const handle = pending ? await pending : null;
if (pending === handlePromise)
handle?.update(bufferedProgress, bufferedVelocity);
}
async function onPointerup(event: PointerEvent) {
if (event.pointerId !== pointerId) return;
if (captured) updateMetrics(event, true);
const pending = handlePromise;
const shouldFinish = captured;
const progress = bufferedProgress;
const velocity = bufferedVelocity;
const completion =
shouldFinish &&
policy.completion &&
element &&
bounds &&
originContext &&
startPoint
? policy.completion({
origin: originContext,
direction: policy.direction,
progress,
velocity,
distance: bufferedDistance,
crossDistance: bufferedCrossDistance,
duration: Math.max(0, event.timeStamp - startTime),
event,
host: element,
bounds,
start: startPoint,
current: pointOf(event, bounds),
} satisfies OriginGestureCompletionContext)
: undefined;
generation += 1;
reset();
const handle = pending ? await pending : null;
if (!shouldFinish || !handle) return;
handle.update(progress, velocity);
await handle.finish(
completion === undefined ? undefined : { commit: completion },
);
}
async function onPointercancel() {
generation += 1;
const pending = handlePromise;
const shouldCancel = captured;
reset();
const handle = pending ? await pending : null;
if (shouldCancel) await handle?.cancel();
}
return {
style: {
// Preserve native scrolling perpendicular to the declared gesture.
touchAction: horizontal ? "pan-y" : "pan-x",
// OriginGesture is commonly the root returned by a view component.
width: "100%",
height: "100%",
},
onPointerdown,
onPointermove: (event) => void onPointermove(event),
onPointerup: (event) => void onPointerup(event),
onPointercancel: () => void onPointercancel(),
};
}

View File

@@ -0,0 +1,38 @@
/**
* Routeless, component-owned scene transitions and gesture recognition for Vue.
*
* The package renders flat, stable Vue component hosts and composes temporary
* origin-relative operation frames. It does not depend on Vue Router or choose
* a globally active view.
*
* @packageDocumentation
*/
export * from "./types";
export * from "./scene";
export * from "./motion";
export * from "./gesture";
export * from "./lifecycle";
/**
* Convenience component that binds one `useOriginGesture()` recognizer to a
* rendered HTML element. See `OriginGestureProps` for its public props.
*/
export { default as OriginGesture } from "./components/OriginGesture.vue";
/**
* Policy-neutral host for multiple completed gesture definitions. The owning
* page builds each definition; this component only installs their recognizers
* and forwards pointer events across the shared surface.
*/
export { default as OriginGestureSurface } from "./components/OriginGestureSurface.vue";
/**
* Renderer for an `OriginScene`. Every live view is mounted as a stable,
* absolutely positioned sibling beneath this component.
*/
export { default as OriginScene } from "./components/OriginScene.vue";
// Makes the library build emit dist/style.css. Applications should import the
// explicit `@native-vue-router/core-v2/style.css` export as shown in the README.
import "./style.css";

View File

@@ -0,0 +1,53 @@
import { computed, inject, type InjectionKey } from "vue";
import type { OriginNodeScope, UseOrigin } from "./types";
/**
* Injection key used by the internal scene-node host to establish origin
* ownership for descendant components.
*
* Application code normally calls {@link useOrigin} instead of injecting this
* key directly.
*
* @internal
*/
export const originNodeScopeKey: InjectionKey<OriginNodeScope> =
Symbol("origin-node-scope");
/**
* Access the scene from the component that owns an interaction declaration.
*
* There is deliberately no `activeView`: the injected node is the origin
* because this component is where the event or application action occurred.
*
* @returns Node-scoped scene state and operation controls.
* @throws If called outside a component rendered by `OriginScene`.
*
* @example
* ```ts
* const origin = useOrigin();
*
* function openProfile() {
* return origin.perform(
* forward(originView(ProfileView), slideLeft),
* );
* }
* ```
*/
export function useOrigin(): UseOrigin {
const scope = inject(originNodeScopeKey);
if (!scope)
throw new Error("useOrigin() must be called inside an <OriginScene> view.");
const context = computed(() => scope.scene.contextFor(scope.nodeKey));
return {
nodeKey: scope.nodeKey,
scene: scope.scene,
context,
view: computed(() => context.value.view),
previous: computed(() => context.value.previous),
canGoBack: computed(() => context.value.canGoBack),
begin: (action) => scope.scene.begin(scope.nodeKey, action),
perform: (action) => scope.scene.perform(scope.nodeKey, action),
};
}

View File

@@ -0,0 +1,325 @@
import type {
OriginAction,
OriginChoreography,
OriginEffect,
OriginEffectSet,
OriginHistoryMode,
OriginNavigationIntent,
OriginPlacement,
OriginView,
} from "./types";
/**
* Preserve type inference while declaring a custom choreography.
*
* The helper performs no runtime transformation. It gives custom routines a
* named, documented construction point and validates their shape in TypeScript.
*
* @param choreography - Side-effect-free visual effect calculator and optional
* release thresholds.
* @returns The same choreography object.
*/
export function defineOriginChoreography(
choreography: OriginChoreography,
): OriginChoreography {
return choreography;
}
/** Options accepted by {@link originAction}. */
export interface OriginActionOptions {
/**
* Target stacking relationship during the operation.
*
* @defaultValue `"above"`
*/
placement?: OriginPlacement;
/**
* History mutation assigned to the created target node.
*
* @defaultValue `"push"`
*/
history?: OriginHistoryMode;
}
/** History options shared by the {@link above} and {@link under} helpers. */
export interface OriginPlacementActionOptions {
/**
* History mutation assigned to the created target node.
*
* @defaultValue `"push"`
*/
history?: OriginHistoryMode;
}
/** Stacking options accepted by retained-history navigation helpers. */
export interface OriginNavigationActionOptions {
/**
* Target stacking relationship during the operation.
*
* @defaultValue `"above"` for {@link forward}, `"under"` for {@link back}
*/
placement?: OriginPlacement;
}
function isChoreography(
value: OriginChoreography | object | undefined,
): value is OriginChoreography {
return (
value !== undefined &&
"effects" in value &&
typeof value.effects === "function"
);
}
/**
* Create the scene mutation invoked by a click, gesture, hardware command, or
* any other application event. `above` and `under` affect stacking only; they
* do not imply a universal navigation direction.
*
* @param target - View recipe to create.
* @param choreography - Visual routine controlling the operation.
* @param options - Stacking and history behavior.
* @returns An action that can be passed to `begin()`, `perform()`, or returned
* from a gesture action factory.
*/
export function originAction(
target: OriginView,
choreography: OriginChoreography,
options: OriginActionOptions = {},
): OriginAction {
return {
target,
choreography,
placement: options.placement ?? "above",
history: options.history ?? "push",
};
}
/**
* Create a retained-history push intent or complete action.
*
* Committing the action parks its origin instance and leaves it mounted until
* a later committed {@link back} action pops the new entry. Omit choreography
* when declaring `.navigate()` inside a gesture builder; provide choreography
* when passing the result directly to `begin()` or `perform()`.
*
* @param target - View recipe created if the push commits.
* @param options - Stacking options for an animation-free navigation intent.
* @returns An animation-free intent for use with a gesture builder.
*/
export function forward(
target: OriginView,
options?: OriginNavigationActionOptions,
): OriginNavigationIntent;
export function forward(
target: OriginView,
choreography: OriginChoreography,
options?: OriginNavigationActionOptions,
): OriginAction;
export function forward(
target: OriginView,
choreographyOrOptions:
OriginChoreography | OriginNavigationActionOptions = {},
options: OriginNavigationActionOptions = {},
): OriginAction | OriginNavigationIntent {
if (isChoreography(choreographyOrOptions)) {
return originAction(target, choreographyOrOptions, {
placement: options.placement ?? "above",
history: "push",
});
}
return {
target,
placement: choreographyOrOptions.placement ?? "above",
history: "push",
};
}
/**
* Create a retained-history back intent or complete action.
*
* The action intentionally has no target recipe. At execution time the scene
* resolves the origin's mounted `previousNodeKey`, reveals that exact instance,
* and removes the current instance only if the operation commits. Omit
* choreography inside gesture `.navigate()`; provide it for a programmatic
* action.
*
* @param options - Stacking options for an animation-free navigation intent.
* @returns An animation-free back intent for use with a gesture builder.
*/
export function back(
options?: OriginNavigationActionOptions,
): OriginNavigationIntent;
export function back(
choreography: OriginChoreography,
options?: OriginNavigationActionOptions,
): OriginAction;
export function back(
choreographyOrOptions:
OriginChoreography | OriginNavigationActionOptions = {},
options: OriginNavigationActionOptions = {},
): OriginAction | OriginNavigationIntent {
if (!isChoreography(choreographyOrOptions)) {
return {
placement: choreographyOrOptions.placement ?? "under",
history: "back",
};
}
return {
choreography: choreographyOrOptions,
placement: options.placement ?? "under",
history: "back",
};
}
/**
* Create an intent or action whose target is stacked above its origin.
*
* This helper controls stacking, not movement direction. The supplied
* choreography may move either component however the application chooses.
* Omitting choreography produces an intent for gesture `.navigate()`.
*
* @param target - View recipe to create.
* @param choreography - Visual routine controlling the operation.
* @param options - Optional history behavior.
*/
export function above(
target: OriginView,
options?: OriginPlacementActionOptions,
): OriginNavigationIntent;
export function above(
target: OriginView,
choreography: OriginChoreography,
options?: OriginPlacementActionOptions,
): OriginAction;
export function above(
target: OriginView,
choreographyOrOptions: OriginChoreography | OriginPlacementActionOptions = {},
options: OriginPlacementActionOptions = {},
): OriginAction | OriginNavigationIntent {
if (isChoreography(choreographyOrOptions)) {
return originAction(target, choreographyOrOptions, {
placement: "above",
history: options.history,
});
}
return {
target,
placement: "above",
history: choreographyOrOptions.history ?? "push",
};
}
/**
* Create an intent or action whose target is stacked underneath its origin.
*
* Commonly used for custom reveal effects, but it has no implicit history
* meaning. Prefer {@link back} for retained-history navigation. Omitting
* choreography produces an intent for gesture `.navigate()`.
*
* @param target - View recipe to create underneath the origin.
* @param choreography - Visual routine controlling the operation.
* @param options - Optional history behavior.
*/
export function under(
target: OriginView,
options?: OriginPlacementActionOptions,
): OriginNavigationIntent;
export function under(
target: OriginView,
choreography: OriginChoreography,
options?: OriginPlacementActionOptions,
): OriginAction;
export function under(
target: OriginView,
choreographyOrOptions: OriginChoreography | OriginPlacementActionOptions = {},
options: OriginPlacementActionOptions = {},
): OriginAction | OriginNavigationIntent {
if (isChoreography(choreographyOrOptions)) {
return originAction(target, choreographyOrOptions, {
placement: "under",
history: options.history,
});
}
return {
target,
placement: "under",
history: choreographyOrOptions.history ?? "push",
};
}
const percent = (value: number) => `${(value * 100).toFixed(4)}%`;
/**
* Native-style forward motion: the target enters above the source while the
* source recedes slightly. These presets are examples; applications can
* replace them with arbitrary `defineOriginChoreography()` callbacks.
*/
export const slideLeft = defineOriginChoreography({
name: "slide-left",
commitThreshold: 0.36,
commitVelocity: 0.6,
effects: ({ progress }): OriginEffectSet => ({
source: {
transform: `translate3d(${percent(progress * -0.24)}, 0, 0) scale(${1 - progress * 0.025})`,
opacity: 1 - progress * 0.16,
},
target: {
transform: `translate3d(${percent(1 - progress)}, 0, 0)`,
},
}),
});
/**
* Back motion reveals the target underneath the source. This is merely a
* visual routine; the {@link back} action selects retained-history behavior.
*/
export const slideRight = defineOriginChoreography({
name: "slide-right",
commitThreshold: 0.36,
commitVelocity: 0.6,
effects: ({ progress }): OriginEffectSet => ({
source: {
transform: `translate3d(${percent(progress)}, 0, 0)`,
},
target: {
transform: `translate3d(${percent(-0.24 + progress * 0.24)}, 0, 0) scale(${0.975 + progress * 0.025})`,
opacity: 0.84 + progress * 0.16,
},
}),
});
/**
* Cross-fade preset that fades the source out while fading the target in.
*
* The default release thresholds from the scene are used.
*/
export const fade = defineOriginChoreography({
name: "fade",
effects: ({ progress }): OriginEffectSet => ({
source: { opacity: 1 - progress },
target: { opacity: progress },
}),
});
/**
* Normalize one contribution before the compositor combines it with effects
* inherited from earlier origin frames.
*
* Most applications should return plain effects from a choreography and let
* the scene call this function. It is exported for custom compositors and
* diagnostics.
*
* @param effect - Optional effect to normalize.
* @param fallbackLayer - Relative layer contribution added to `effect.layer`.
* @returns A defined effect with a numeric layer.
*/
export function normalizedEffect(
effect: OriginEffect | undefined,
fallbackLayer = 0,
): OriginEffect {
return {
...effect,
layer: (effect?.layer ?? 0) + fallbackLayer,
};
}

View File

@@ -0,0 +1,315 @@
import {
createApp,
defineComponent,
h,
nextTick,
onMounted,
onUnmounted,
type Component,
} from "vue";
import { afterEach, describe, expect, it } from "vitest";
import OriginScene from "./components/OriginScene.vue";
import {
above,
back,
defineOriginChoreography,
forward,
under,
} from "./motion";
import { createOriginScene, originView } from "./scene";
const mountedApps: Array<ReturnType<typeof createApp>> = [];
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
});
function component(name: string): Component {
return defineComponent({
name,
render: () => h("div", { "data-test-view": name }, name),
});
}
const layeredMotion = defineOriginChoreography({
name: "test-layered-motion",
effects: ({ progress }) => ({
source: { transform: `translateX(${-progress * 100}px)` },
target: { transform: `translateX(${(1 - progress) * 100}px)` },
}),
});
describe("origin-relative scene graph", () => {
it("composes X→Y and Y→Z as independent transform layers", async () => {
const x = originView(component("X"), undefined, { key: "x" });
const y = originView(component("Y"), undefined, { key: "y" });
const z = originView(component("Z"), undefined, { key: "z" });
const scene = createOriginScene({ initial: x });
const xKey = scene.nodes.value[0]!.key;
const xy = await scene.begin(xKey, above(y, layeredMotion));
xy.update(0.5);
const yz = await scene.begin(xy.targetKey, above(z, layeredMotion));
yz.update(0.25);
/*
* Y inherits the target half of X→Y, then adds its own source half of
* Y→Z. Z inherits X→Y as well, but receives Y→Z's target half.
*/
expect(scene.styleForNode(xy.targetKey).transform).toBe(
"translateX(50px) translateX(-25px)",
);
expect(scene.styleForNode(xy.targetKey)).toMatchObject({
position: "absolute",
inset: "0",
width: "100%",
height: "100%",
});
expect(scene.styleForNode(yz.targetKey).transform).toBe(
"translateX(50px) translateX(75px)",
);
expect(scene.nodes.value.map((node) => node.view.name)).toEqual([
"X",
"Y",
"Z",
]);
});
it("splices completed operations in either order without losing descendants", async () => {
const scene = createOriginScene({
initial: originView(component("X"), undefined, { key: "x" }),
});
const xKey = scene.nodes.value[0]!.key;
const xy = await scene.begin(
xKey,
above(originView(component("Y"), undefined, { key: "y" }), layeredMotion),
);
xy.update(0.8);
const yz = await scene.begin(
xy.targetKey,
above(originView(component("Z"), undefined, { key: "z" }), layeredMotion),
);
yz.update(0.6);
// Completing the newer edge first parks Y while Z replaces it as the
// visual target. Y remains mounted as Z's retained previous entry.
await yz.finish({ commit: true, animate: false });
expect(scene.nodes.value.map((node) => node.view.name)).toEqual([
"X",
"Y",
"Z",
]);
expect(
scene.nodes.value.find((node) => node.view.name === "Y")?.state,
).toBe("parked");
expect(scene.operations.value).toHaveLength(1);
expect(scene.operations.value[0]?.targetKey).toBe(yz.targetKey);
expect(scene.styleForNode(yz.targetKey).transform).toMatch(
/^translateX\(19\.9.+px\)$/,
);
await xy.finish({ commit: true, animate: false });
expect(
scene.nodes.value.map((node) => [node.view.name, node.state]),
).toEqual([
["X", "parked"],
["Y", "parked"],
["Z", "active"],
]);
expect(scene.operations.value).toHaveLength(0);
expect(scene.styleForNode(yz.targetKey).transform).toBe("none");
expect(scene.styleForNode(xKey).visibility).toBe("hidden");
});
it("does not remount a target when its incoming operation is collapsed", async () => {
let yMounts = 0;
const X = component("X");
const Y = defineComponent({
name: "Y",
setup() {
onMounted(() => {
yMounts += 1;
});
return () => h("div", "Y");
},
});
const Z = component("Z");
const scene = createOriginScene({
initial: originView(X, undefined, { key: "x" }),
});
const host = document.createElement("div");
document.body.append(host);
const app = createApp({
render: () => h(OriginScene, { scene }),
});
mountedApps.push(app);
app.mount(host);
expect(
(host.querySelector(".nvo-scene") as HTMLElement | null)?.style.position,
).toBe("relative");
const xy = await scene.begin(
scene.nodes.value[0]!.key,
above(originView(Y, undefined, { key: "y" }), layeredMotion),
);
const yz = await scene.begin(
xy.targetKey,
above(originView(Z, undefined, { key: "z" }), layeredMotion),
);
await nextTick();
expect(yMounts).toBe(1);
expect(
(host.querySelector('[data-origin-view="Y"]') as HTMLElement | null)
?.style.position,
).toBe("absolute");
// Y remains the same flat, keyed host while X→Y disappears around it.
await xy.finish({ commit: true, animate: false });
await nextTick();
expect(yMounts).toBe(1);
expect(scene.nodes.value.map((node) => node.view.name)).toEqual([
"X",
"Y",
"Z",
]);
await yz.cancel({ animate: false });
expect(
scene.nodes.value.map((node) => [node.view.name, node.state]),
).toEqual([
["X", "parked"],
["Y", "active"],
]);
});
it("reuses the retained previous node and pops only the current entry on back", async () => {
let xMounts = 0;
let xUnmounts = 0;
let yUnmounts = 0;
const X = defineComponent({
name: "X",
setup() {
onMounted(() => {
xMounts += 1;
});
onUnmounted(() => {
xUnmounts += 1;
});
return () =>
h(
"div",
{
"data-scroll": "feed",
style: { height: "100px", overflow: "auto" },
},
h("div", { style: { height: "2000px" } }, "Feed"),
);
},
});
const Y = defineComponent({
name: "Y",
setup() {
onUnmounted(() => {
yUnmounts += 1;
});
return () => h("div", "Y");
},
});
const x = originView(X, { message: "original" }, { key: "x" });
const y = originView(Y, undefined, { key: "y" });
const scene = createOriginScene({ initial: x });
const host = document.createElement("div");
document.body.append(host);
const app = createApp({
render: () => h(OriginScene, { scene }),
});
mountedApps.push(app);
app.mount(host);
await nextTick();
const xKey = scene.nodes.value[0]!.key;
const originalScroller = host.querySelector(
'[data-scroll="feed"]',
) as HTMLElement;
originalScroller.scrollTop = 842;
const xy = await scene.begin(xKey, forward(y, layeredMotion));
await xy.finish({ commit: true, animate: false });
await nextTick();
const yContext = scene.contextFor(xy.targetKey);
expect(yContext.previous).toBe(x);
expect(yContext.history).toEqual([x]);
expect(xMounts).toBe(1);
expect(xUnmounts).toBe(0);
expect(scene.nodes.value.find((node) => node.key === xKey)?.state).toBe(
"parked",
);
expect(host.querySelector('[data-scroll="feed"]')).toBe(originalScroller);
expect(originalScroller.scrollTop).toBe(842);
const yx = await scene.begin(xy.targetKey, back(layeredMotion));
expect(yx.targetKey).toBe(xKey);
await nextTick();
expect(host.querySelector('[data-scroll="feed"]')).toBe(originalScroller);
expect(originalScroller.scrollTop).toBe(842);
await yx.finish({ commit: true, animate: false });
await nextTick();
expect(scene.nodes.value.map((node) => node.key)).toEqual([xKey]);
expect(scene.contextFor(xKey).history).toEqual([]);
expect(scene.nodes.value[0]?.state).toBe("active");
expect(xMounts).toBe(1);
expect(xUnmounts).toBe(0);
expect(yUnmounts).toBe(1);
expect(originalScroller.scrollTop).toBe(842);
});
it("re-parks the retained target when a back operation is cancelled", async () => {
const x = originView(component("X"), undefined, { key: "x" });
const y = originView(component("Y"), undefined, { key: "y" });
const scene = createOriginScene({ initial: x });
const xKey = scene.nodes.value[0]!.key;
const xy = await scene.begin(xKey, above(y, layeredMotion));
await xy.finish({ commit: true, animate: false });
const yx = await scene.begin(
xy.targetKey,
under(x, layeredMotion, { history: "back" }),
);
expect(yx.targetKey).toBe(xKey);
await yx.cancel({ animate: false });
expect(scene.operations.value).toHaveLength(0);
expect(
scene.nodes.value.map((node) => [node.view.name, node.state]),
).toEqual([
["X", "parked"],
["Y", "active"],
]);
});
it("hands an immediate back gesture from a settling push to the same instances", async () => {
const x = originView(component("X"), undefined, { key: "x" });
const y = originView(component("Y"), undefined, { key: "y" });
const scene = createOriginScene({ initial: x });
const xKey = scene.nodes.value[0]!.key;
const xy = await scene.begin(xKey, above(y, layeredMotion));
xy.update(0.8, 1);
const forwardSettlement = xy.finish({ commit: true });
const yx = await scene.begin(
xy.targetKey,
under(x, layeredMotion, { history: "back" }),
);
expect(yx.targetKey).toBe(xKey);
await yx.finish({ commit: true, animate: false });
await forwardSettlement;
expect(scene.operations.value).toHaveLength(0);
expect(scene.nodes.value.map((node) => [node.key, node.state])).toEqual([
[xKey, "active"],
]);
});
});

View File

@@ -0,0 +1,701 @@
import {
computed,
markRaw,
nextTick,
reactive,
shallowReactive,
shallowRef,
type Component,
type CSSProperties,
} from "vue";
import { normalizedEffect } from "./motion";
import type {
MutableOriginOperation,
OriginAction,
OriginChoreographyContext,
OriginContext,
OriginEffect,
OriginEffectSet,
OriginFinishOptions,
OriginOperation,
OriginOperationHandle,
OriginRect,
OriginScene,
OriginSceneNode,
OriginSceneNodeState,
OriginView,
} from "./types";
interface SceneNodeState {
key: string;
sequence: number;
view: OriginView;
previousNodeKey?: string;
state: OriginSceneNodeState;
incomingOperationId?: number;
}
/** Options used to create an independent origin scene. */
export interface CreateOriginSceneOptions {
/**
* One initial root recipe, or several independent roots rendered in the same
* flat scene. Most applications begin with one root.
*/
initial: OriginView | readonly OriginView[];
}
/** Optional developer-facing metadata for an {@link OriginView} recipe. */
export interface OriginViewOptions {
/**
* Stable recipe identity used in diagnostics and generated node-key prefixes.
* It does not preserve or reuse a mounted Vue component instance.
*/
key?: string;
/** Human-readable label used by inspectors and DOM data attributes. */
name?: string;
}
let viewSequence = 0;
/**
* Turn a component and props into a lightweight, reusable view recipe.
*
* Components are marked raw so Vue never attempts to proxy their definitions
* when recipes are placed in reactive scene/history structures.
*
* @typeParam Props - Props passed to the component when the recipe is mounted.
* @param component - Vue component definition to mount.
* @param props - Props captured by the recipe.
* @param options - Optional recipe identity and diagnostic name.
* @returns An immutable, reusable component recipe. It is not a Vue instance.
*
* @example
* ```ts
* const profile = originView(
* ProfileView,
* { userId: "42" },
* { key: "profile-42", name: "Profile" },
* );
* ```
*/
export function originView<
Props extends Record<string, unknown> = Record<string, unknown>,
>(
component: Component,
props?: Props,
options: OriginViewOptions = {},
): OriginView<Props> {
const inferredName =
options.name ??
(typeof component === "object" && "name" in component
? String(component.name)
: undefined);
return markRaw({
component: markRaw(component),
props,
key: options.key ?? `${inferredName ?? "view"}-${++viewSequence}`,
name: inferredName,
});
}
function clamp(value: number) {
return Math.max(0, Math.min(1, value));
}
function elementRect(element: HTMLElement | null | undefined) {
if (!element) return undefined;
const rect = element.getBoundingClientRect();
return {
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
};
}
function defaultViewport(element: HTMLElement | null): OriginRect {
return (
elementRect(element) ?? {
top: 0,
left: 0,
width: typeof window === "undefined" ? 1 : window.innerWidth,
height: typeof window === "undefined" ? 1 : window.innerHeight,
}
);
}
function prefersReducedMotion() {
return (
typeof window === "undefined" ||
!window.requestAnimationFrame ||
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
);
}
/**
* Create one independent scene/history context.
*
* The implementation stores nodes flat. Operation edges describe how their
* visual effects compose, but a Vue component never moves to a different VNode
* parent as edges are added or removed. This is what preserves component state
* while X→Y and Y→Z overlap.
*
* @param options - Initial root view recipe or recipes.
* @returns A self-contained reactive scene. Render it through `OriginScene`.
*
* @example
* ```ts
* const scene = createOriginScene({
* initial: originView(HomeView, undefined, { key: "home" }),
* });
* ```
*/
export function createOriginScene(
options: CreateOriginSceneOptions,
): OriginScene {
const nodes = shallowReactive(new Map<string, SceneNodeState>());
const operations = shallowReactive(new Map<number, MutableOriginOperation>());
const roots = shallowRef<string[]>([]);
const elements = new Map<string, HTMLElement>();
let container: HTMLElement | null = null;
let nodeSequence = 0;
let operationSequence = 0;
function uniqueNodeKey(view: OriginView) {
return `${view.key ?? view.name ?? "view"}::${++nodeSequence}`;
}
function addNode(
view: OriginView,
previousNodeKey?: string,
incomingOperationId?: number,
) {
const node: SceneNodeState = shallowReactive({
key: uniqueNodeKey(view),
sequence: nodeSequence,
view: markRaw(view),
previousNodeKey,
state: incomingOperationId ? "transitioning" : "active",
incomingOperationId,
});
nodes.set(node.key, node);
return node;
}
const initialViews = Array.isArray(options.initial)
? options.initial
: [options.initial];
for (const initial of initialViews) {
const node = addNode(initial);
roots.value = [...roots.value, node.key];
}
function historyNodesFor(node: SceneNodeState) {
const history: SceneNodeState[] = [];
const visited = new Set<string>([node.key]);
let previousKey = node.previousNodeKey;
while (previousKey && !visited.has(previousKey)) {
visited.add(previousKey);
const previous = nodes.get(previousKey);
if (!previous) break;
history.unshift(previous);
previousKey = previous.previousNodeKey;
}
return history;
}
function contextFor(nodeKey: string): OriginContext {
const node = nodes.get(nodeKey);
if (!node)
throw new Error(`Origin scene node "${nodeKey}" no longer exists.`);
const historyNodes = historyNodesFor(node);
return {
nodeKey,
view: node.view,
canGoBack: Boolean(node.previousNodeKey),
previous: historyNodes.at(-1)?.view,
history: historyNodes.map((entry) => entry.view),
};
}
function previousNodeForAction(origin: SceneNodeState, action: OriginAction) {
switch (action.history ?? "push") {
case "push":
return origin.key;
case "back":
return origin.previousNodeKey;
}
}
function operationContext(
operation: MutableOriginOperation,
): OriginChoreographyContext {
return {
progress: operation.progress,
velocity: operation.velocity,
phase: operation.phase,
intent: operation.intent,
originRect: operation.originRect,
targetRect: operation.targetRect,
viewport: defaultViewport(container),
};
}
function effectSetFor(operation: MutableOriginOperation): OriginEffectSet {
return operation.choreography.effects(operationContext(operation));
}
/**
* Return the origin-frame effects inherited by a node. The source effect of
* an ancestor is intentionally excluded: descendants inherit the target side
* of an operation, while only the initiating component receives its source
* side.
*/
function inheritedEffects(
nodeKey: string,
visited = new Set<string>(),
): OriginEffect[] {
if (visited.has(nodeKey)) return [];
visited.add(nodeKey);
const node = nodes.get(nodeKey);
const incoming = node?.incomingOperationId
? operations.get(node.incomingOperationId)
: undefined;
if (!node || !incoming) return [];
const effects = effectSetFor(incoming);
const targetLayer = incoming.placement === "above" ? 1 : -1;
return [
...inheritedEffects(incoming.originKey, visited),
normalizedEffect(effects.frame),
normalizedEffect(effects.target, targetLayer),
];
}
function visualEffects(nodeKey: string) {
const result = inheritedEffects(nodeKey);
const outgoing = [...operations.values()].find(
(operation) => operation.originKey === nodeKey,
);
if (!outgoing) return result;
const effects = effectSetFor(outgoing);
return [
...result,
normalizedEffect(effects.frame),
normalizedEffect(effects.source),
];
}
/**
* Collapse independent effect layers into one host style. Geometry remains
* composable; arbitrary CSS properties use normal local-last precedence.
*/
function styleForNode(nodeKey: string): CSSProperties {
const node = nodes.get(nodeKey);
if (!node) return {};
const transforms: string[] = [];
let opacity = 1;
let layer = 0;
/*
* These rules are compositor invariants rather than visual theming. Keep
* them inline so a missing optional package stylesheet can never place
* scene nodes back into normal block/flex flow and vertically stack views.
*/
const style: CSSProperties = {
position: "absolute",
inset: "0",
width: "100%",
height: "100%",
overflow: "hidden",
};
for (const effect of visualEffects(nodeKey)) {
if (effect.transform && effect.transform !== "none")
transforms.push(effect.transform);
if (effect.opacity !== undefined) opacity *= effect.opacity;
layer += effect.layer ?? 0;
if (effect.style) {
const effectStyle = effect.style;
Object.assign(style, effectStyle);
if (
typeof effectStyle.transform === "string" &&
effectStyle.transform !== "none"
)
transforms.push(effectStyle.transform);
if (effectStyle.opacity !== undefined) {
const numericOpacity = Number(effectStyle.opacity);
if (Number.isFinite(numericOpacity)) opacity *= numericOpacity;
}
}
}
// Explicitly assign the composited properties after Object.assign so an
// individual effect cannot accidentally replace inherited transform work.
style.transform = transforms.length ? transforms.join(" ") : "none";
style.opacity = String(clamp(opacity));
style.zIndex = 1_000_000 + layer * 10_000 + node.sequence;
style.pointerEvents = isNodeInteractive(nodeKey) ? "auto" : "none";
if (node.state === "parked") {
// Parked entries remain mounted in their original flat hosts. Keeping
// the DOM preserves local component state and nested scroll positions,
// while visibility/inert handling removes them from presentation.
style.visibility = "hidden";
style.contentVisibility = "hidden";
}
return style;
}
function outgoingFor(nodeKey: string) {
return [...operations.values()].find(
(operation) => operation.originKey === nodeKey,
);
}
function isNodeInteractive(nodeKey: string) {
const node = nodes.get(nodeKey);
if (!node || node.state === "parked") return false;
// A target being cancelled is already scheduled to disappear. A source
// settling toward commit has ceded new interactions to the retained scene
// beneath/above it. This is node-local fate, not a global "active view".
const incoming = node.incomingOperationId
? operations.get(node.incomingOperationId)
: undefined;
if (incoming?.intent === "cancel") return false;
return outgoingFor(nodeKey)?.intent !== "commit";
}
function updateOperation(id: number, progress: number, velocity = 0) {
const operation = operations.get(id);
if (!operation || operation.phase === "finished") return;
operation.progress = clamp(progress);
operation.velocity = velocity;
if (operation.phase === "preparing") return;
operation.phase = "interactive";
}
function removeNode(nodeKey: string) {
elements.delete(nodeKey);
nodes.delete(nodeKey);
roots.value = roots.value.filter((key) => key !== nodeKey);
}
/**
* Remove a newly created forward branch while leaving the origin and all
* earlier retained entries untouched.
*/
function removeRetainedBranch(nodeKey: string, visited = new Set<string>()) {
if (visited.has(nodeKey)) return;
visited.add(nodeKey);
for (const node of [...nodes.values()]) {
if (node.previousNodeKey === nodeKey)
removeRetainedBranch(node.key, visited);
}
for (const operation of [...operations.values()]) {
if (
operation.originKey === nodeKey ||
operation.targetKey === nodeKey ||
operation.entryTargetKey === nodeKey
)
operations.delete(operation.id);
}
removeNode(nodeKey);
}
function refreshVisibleState(node: SceneNodeState) {
if (node.state === "parked") return;
node.state =
node.incomingOperationId || outgoingFor(node.key)
? "transitioning"
: "active";
}
/**
* Transfer an operation's visual graph position to its target without moving
* either Vue VNode. The history chain is intentionally independent from this
* temporary coordinate graph.
*/
function spliceVisualTarget(origin: SceneNodeState, target: SceneNodeState) {
const parentOperation = origin.incomingOperationId
? operations.get(origin.incomingOperationId)
: undefined;
if (parentOperation) {
parentOperation.targetKey = target.key;
target.incomingOperationId = parentOperation.id;
} else {
roots.value = roots.value.map((key) =>
key === origin.key ? target.key : key,
);
target.incomingOperationId = undefined;
}
}
/**
* Finalize a committed operation.
*
* Push keeps the origin mounted and parks it. Back reuses the existing
* previous node and removes only the entry being popped.
*/
function commitOperation(id: number) {
const operation = operations.get(id);
if (!operation) return false;
const origin = nodes.get(operation.originKey);
const target = nodes.get(operation.targetKey);
if (!origin || !target) return false;
spliceVisualTarget(origin, target);
operation.phase = "finished";
operations.delete(operation.id);
if (operation.history === "push") {
origin.incomingOperationId = undefined;
origin.state = "parked";
} else {
// A committed back pops only the current retained history entry.
removeNode(origin.key);
}
target.state = "active";
refreshVisibleState(target);
return true;
}
function cancelOperation(id: number) {
const operation = operations.get(id);
if (!operation) return;
const origin = nodes.get(operation.originKey);
const target = nodes.get(operation.entryTargetKey);
operation.phase = "finished";
operations.delete(id);
if (operation.history === "back") {
if (target) {
target.incomingOperationId = undefined;
target.state = "parked";
}
} else {
removeRetainedBranch(operation.entryTargetKey);
}
if (origin) {
origin.state = "active";
refreshVisibleState(origin);
}
}
async function settle(id: number, targetProgress: 0 | 1, animate: boolean) {
const operation = operations.get(id);
if (!operation) return;
if (!animate || prefersReducedMotion()) {
operation.progress = targetProgress;
operation.velocity = 0;
return;
}
await new Promise<void>((resolve) => {
let position = operation.progress;
let velocity = Math.max(-8, Math.min(8, operation.velocity));
let previous = performance.now();
const frame = (time: number) => {
const live = operations.get(id);
if (!live) return resolve();
// A damped spring makes release velocity continuous with pointer motion
// without imposing a fixed-duration easing on custom choreographies.
const elapsed = Math.min(
0.032,
Math.max(0.001, (time - previous) / 1000),
);
previous = time;
const iterations = Math.max(1, Math.ceil(elapsed / (1 / 120)));
const dt = elapsed / iterations;
for (let index = 0; index < iterations; index += 1) {
const acceleration =
(targetProgress - position) * 280 - velocity * 30;
velocity += acceleration * dt;
position += velocity * dt;
}
const done =
Math.abs(targetProgress - position) < 0.002 &&
Math.abs(velocity) < 0.02;
live.progress = done ? targetProgress : clamp(position);
live.velocity = done ? 0 : velocity;
if (done) resolve();
else window.requestAnimationFrame(frame);
};
window.requestAnimationFrame(frame);
});
}
async function finishOperation(
id: number,
options: OriginFinishOptions = {},
) {
const operation = operations.get(id);
if (!operation) return false;
const threshold = operation.choreography.commitThreshold ?? 0.36;
// const velocityThreshold = operation.choreography.commitVelocity ?? 0.9;
const shouldCommit =
options.commit ??
(operation.progress >= threshold ||
(operation.progress >= 0.00 &&
operation.velocity >= 0.3));
// operation.velocity >= velocityThreshold));
// The outcome is known synchronously at release. The target can therefore
// originate another gesture while this operation is only visually settling.
operation.intent = shouldCommit ? "commit" : "cancel";
operation.phase = "settling";
await settle(id, shouldCommit ? 1 : 0, options.animate ?? true);
if (!operations.has(id)) return shouldCommit;
if (shouldCommit) return commitOperation(id);
cancelOperation(id);
return false;
}
async function begin(
originKey: string,
action: OriginAction,
): Promise<OriginOperationHandle> {
const origin = nodes.get(originKey);
if (!origin)
throw new Error(`Cannot animate from missing origin "${originKey}".`);
if (origin.state === "parked")
throw new Error(`Cannot animate from parked origin "${originKey}".`);
if (outgoingFor(originKey))
throw new Error(
`Origin "${originKey}" already has an outgoing operation. ` +
"A descendant target may start its own operation instead.",
);
const history = action.history ?? "push";
let target: SceneNodeState;
if (history === "back") {
const previousKey = origin.previousNodeKey;
if (!previousKey)
throw new Error(
`Cannot go back from origin "${originKey}" without a previous entry.`,
);
const retainedTarget = nodes.get(previousKey);
if (!retainedTarget)
throw new Error(
`Cannot go back to missing retained entry "${previousKey}".`,
);
/*
* If back starts while the immediately preceding forward spring is still
* settling, collapse that already-committed edge first. Creating a new
* edge back to its origin would otherwise form X→Y→X. Reciprocal
* choreographies begin at the same visual endpoints, so this handoff is
* continuous without remounting either node.
*/
const incoming = origin.incomingOperationId
? operations.get(origin.incomingOperationId)
: undefined;
if (incoming?.originKey === retainedTarget.key) {
if (incoming.intent !== "commit")
throw new Error(
"Cannot go back through an undecided forward operation.",
);
commitOperation(incoming.id);
}
if (outgoingFor(retainedTarget.key))
throw new Error(
`Retained target "${retainedTarget.key}" already has an outgoing operation.`,
);
if (retainedTarget.incomingOperationId)
throw new Error(
`Retained target "${retainedTarget.key}" is already transitioning.`,
);
target = retainedTarget;
} else {
if (!action.target)
throw new Error(
`A "${history}" operation requires a target view recipe.`,
);
target = addNode(action.target, previousNodeForAction(origin, action));
}
const operationId = ++operationSequence;
target.incomingOperationId = operationId;
target.state = "transitioning";
origin.state = "transitioning";
const operation = reactive<MutableOriginOperation>({
id: operationId,
originKey,
targetKey: target.key,
entryTargetKey: target.key,
choreography: markRaw(action.choreography),
placement: action.placement ?? "above",
history,
progress: 0,
velocity: 0,
phase: "preparing",
intent: "undecided",
originRect: elementRect(elements.get(originKey)),
});
operations.set(operation.id, operation);
// Let Vue mount the target before measuring it. Gesture composables buffer
// pointer progress while this short preparation step is pending.
await nextTick();
operation.targetRect = elementRect(elements.get(target.key));
operation.phase = "interactive";
return {
id: operation.id,
originKey,
targetKey: target.key,
update: (progress, velocity) =>
updateOperation(operation.id, progress, velocity),
finish: (finishOptions) => finishOperation(operation.id, finishOptions),
cancel: async (cancelOptions) => {
await finishOperation(operation.id, {
commit: false,
animate: cancelOptions?.animate,
});
},
};
}
async function perform(originKey: string, action: OriginAction) {
const operation = await begin(originKey, action);
return operation.finish({ commit: true });
}
return {
nodes: computed(
() =>
[...nodes.values()].map((node) => ({
...node,
history: historyNodesFor(node).map((entry) => entry.view),
})) as readonly OriginSceneNode[],
),
operations: computed(
() => [...operations.values()] as readonly OriginOperation[],
),
roots,
contextFor,
begin,
perform,
registerElement(nodeKey, element) {
if (element) elements.set(nodeKey, element);
else elements.delete(nodeKey);
},
registerContainer(element) {
container = element;
},
styleForNode,
isNodeInteractive,
};
}

View File

@@ -0,0 +1,37 @@
.nvo-scene {
position: relative;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
isolation: isolate;
contain: layout paint;
background: #000;
}
.nvo-node {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: hidden;
will-change: transform, opacity;
backface-visibility: hidden;
}
.nvo-node > * {
width: 100%;
height: 100%;
}
.nvo-gesture {
-webkit-user-select: none;
user-select: none;
}
@media (prefers-reduced-motion: reduce) {
.nvo-node {
will-change: auto;
}
}

View File

@@ -0,0 +1,800 @@
import type { Component, ComputedRef, CSSProperties, ShallowRef } from "vue";
/**
* A view is a recipe for creating a Vue component, not a mounted instance.
*
* Forward navigation uses the recipe to create a mounted history entry. That
* instance remains mounted until a committed back operation pops it.
*
* @typeParam Props - The props accepted by the component recipe.
*/
export interface OriginView<
Props extends Record<string, unknown> = Record<string, unknown>,
> {
/** The Vue component definition that will be mounted for this recipe. */
readonly component: Component;
/** Props passed to the component when the recipe is mounted. */
readonly props?: Readonly<Props>;
/**
* A developer-facing recipe identity used in diagnostics and as the prefix
* of generated scene-node keys. It does not make Vue reuse an instance.
*/
readonly key?: string;
/** A human-readable label used by diagnostics and DOM data attributes. */
readonly name?: string;
}
/**
* The lifecycle phase of an operation edge.
*
* - `preparing`: the target has been added and Vue is mounting it.
* - `interactive`: progress may be controlled by a gesture or application.
* - `settling`: the commit/cancel decision is fixed and the spring is running.
* - `finished`: the graph rewrite or cancellation cleanup has completed.
*/
export type OriginOperationPhase =
"preparing" | "interactive" | "settling" | "finished";
/**
* The outcome selected for an operation.
*
* Intent remains `undecided` until `finish()` is called. It becomes final
* before the settling animation completes, allowing the retained target to
* originate its own operation immediately.
*/
export type OriginOperationIntent = "undecided" | "commit" | "cancel";
/**
* Controls how an operation participates in retained instance history.
*
* - `push`: create a target whose previous entry is the mounted origin.
* - `back`: reuse the mounted previous entry and pop the origin on commit.
*/
export type OriginHistoryMode = "push" | "back";
/**
* The target's stacking relationship to its origin while an operation exists.
*/
export type OriginPlacement = "above" | "under";
/**
* Visibility/lifecycle role of a mounted scene node.
*
* - `active`: currently exposed for normal interaction.
* - `transitioning`: participating in at least one live operation edge.
* - `parked`: retained in history but visually hidden and inert.
*/
export type OriginSceneNodeState = "active" | "transitioning" | "parked";
/** A rectangle measured in viewport CSS pixels. */
export interface OriginRect {
/** Distance from the viewport's top edge in CSS pixels. */
top: number;
/** Distance from the viewport's left edge in CSS pixels. */
left: number;
/** Rectangle width in CSS pixels. */
width: number;
/** Rectangle height in CSS pixels. */
height: number;
}
/**
* One composable contribution to a scene node's final visual style.
*
* Transforms are concatenated in origin-to-descendant order and opacity values
* are multiplied. This lets X→Y and Y→Z affect Y simultaneously without either
* routine replacing the other routine's CSS transform.
*/
export interface OriginEffect {
/**
* A CSS transform contribution. Transform strings from inherited and local
* origin frames are concatenated rather than replacing one another.
*/
transform?: string;
/**
* An opacity contribution between `0` and `1`. Contributions from multiple
* frames are multiplied and the final value is clamped.
*/
opacity?: number;
/**
* Relative stacking contribution. `above()` defaults the target to +1 and
* `under()` defaults it to -1.
*/
layer?: number;
/**
* Escape hatch for non-geometric effects such as filter, clipPath, or
* borderRadius. Later/local effects override inherited properties.
*/
style?: CSSProperties;
}
/**
* Independent visual contributions calculated for one operation edge.
*
* Any omitted contribution is treated as an identity effect.
*/
export interface OriginEffectSet {
/**
* Applied to both source and target. This is useful for moving an entire
* origin-relative coordinate frame.
*/
frame?: OriginEffect;
/** Applied to the component that initiated this operation. */
source?: OriginEffect;
/** Applied to the component created by this operation and its descendants. */
target?: OriginEffect;
}
/** Values supplied whenever a choreography calculates its visual effects. */
export interface OriginChoreographyContext {
/** Normalized operation progress, clamped to the inclusive range `0..1`. */
progress: number;
/**
* Normalized progress units per second. Positive velocity moves toward
* commit; negative velocity moves back toward cancellation.
*/
velocity: number;
/** Current lifecycle phase of the operation. */
phase: OriginOperationPhase;
/** Commit/cancel outcome, if release has already selected one. */
intent: OriginOperationIntent;
/** Origin host bounds captured immediately before the target mounts. */
originRect?: OriginRect;
/** Target host bounds measured after Vue mounts the target. */
targetRect?: OriginRect;
/** Scene-container bounds, or the browser viewport when no container exists. */
viewport: OriginRect;
}
/**
* A choreography describes visual relationships only. Component creation,
* history, and cleanup are performed by the scene operation that uses it.
*/
export interface OriginChoreography {
/** Optional diagnostic name surfaced by scene inspectors and devtools. */
readonly name?: string;
/**
* Calculate source, target, and shared-frame contributions for the current
* operation state. This function should be deterministic and side-effect
* free because it can run many times per animation frame.
*/
effects(context: OriginChoreographyContext): OriginEffectSet;
/**
* Gesture progress required to retain the target after release.
*
* @defaultValue `0.36`
*/
readonly commitThreshold?: number;
/**
* Normalized positive release velocity that can commit a deliberate flick
* once progress has reached at least `0.06`.
*
* @defaultValue `0.9`
*/
readonly commitVelocity?: number;
}
/** A complete request to create and animate a target view from an origin. */
export interface OriginAction {
/**
* Recipe for a newly created target. A back action omits this because the
* scene resolves its already-mounted previous entry.
*/
readonly target?: OriginView;
/** Visual relationship applied to the source, target, and shared frame. */
readonly choreography: OriginChoreography;
/**
* Target stacking relationship during the operation.
*
* @defaultValue `"above"`
*/
readonly placement?: OriginPlacement;
/**
* History mutation applied to the target recipe.
*
* @defaultValue `"push"`
*/
readonly history?: OriginHistoryMode;
}
/**
* A scene mutation without visual choreography.
*
* Gesture builders keep navigation intent separate from animation so the same
* destination can be paired with different component-local interactions.
* Calling `.animate()` materializes this intent as an {@link OriginAction}.
*/
export interface OriginNavigationIntent {
/**
* Recipe for a newly created target. Back navigation omits this because the
* scene resolves the retained previous instance.
*/
readonly target?: OriginView;
/** Target stacking relationship while the gesture operation is visible. */
readonly placement?: OriginPlacement;
/** Retained-history mutation performed if the gesture commits. */
readonly history: OriginHistoryMode;
}
/**
* Read-only diagnostic representation of one mounted scene node.
*
* A node corresponds to one currently mounted Vue component instance.
*/
export interface OriginSceneNode {
/** Unique identity for this particular mounted scene node. */
readonly key: string;
/** Monotonically increasing creation order within the scene. */
readonly sequence: number;
/** Recipe used to create the node's component. */
readonly view: OriginView;
/** Recipes for the retained instance chain preceding this node. */
readonly history: readonly OriginView[];
/** Key of the retained mounted entry immediately before this node. */
readonly previousNodeKey?: string;
/** Current visibility/lifecycle role of this mounted instance. */
readonly state: OriginSceneNodeState;
/** Live operation currently positioning this node as its target. */
readonly incomingOperationId?: number;
}
/** Read-only diagnostic representation of one live operation edge. */
export interface OriginOperation {
/** Unique, monotonically increasing operation identity within the scene. */
readonly id: number;
/** Key of the mounted component that originated the operation. */
readonly originKey: string;
/** Key of the newly created or retained target component. */
readonly targetKey: string;
/** Original history entry targeted before visual-edge rewrites. */
readonly entryTargetKey: string;
/** Choreography currently calculating this edge's effects. */
readonly choreography: OriginChoreography;
/** Target stacking relationship to the origin. */
readonly placement: OriginPlacement;
/** Retained-history behavior performed if this operation commits. */
readonly history: OriginHistoryMode;
/** Normalized progress in the inclusive range `0..1`. */
readonly progress: number;
/** Latest normalized velocity in progress units per second. */
readonly velocity: number;
/** Current operation lifecycle phase. */
readonly phase: OriginOperationPhase;
/** Selected operation outcome. */
readonly intent: OriginOperationIntent;
}
/**
* Node-local information supplied to component-owned action factories.
*
* There is deliberately no global `current` or `active` view. The component
* handling the event is the origin represented by this context.
*/
export interface OriginContext {
/** Unique key of the mounted node that owns the interaction. */
readonly nodeKey: string;
/** Recipe used to create the origin node. */
readonly view: OriginView;
/** Whether this retained history entry has a mounted previous instance. */
readonly canGoBack: boolean;
/** Recipe belonging to the retained previous instance, when available. */
readonly previous?: OriginView;
/** Recipes belonging to all retained instances preceding this node. */
readonly history: readonly OriginView[];
}
/** Options controlling how a manually managed operation is resolved. */
export interface OriginFinishOptions {
/**
* Override the choreography's progress/velocity decision. Omit it to use the
* choreography's commit thresholds.
*/
commit?: boolean;
/**
* Whether to run the settling spring before finalizing the graph.
*
* @defaultValue `true`
*/
animate?: boolean;
}
/** Imperative controller for one mounted, live operation edge. */
export interface OriginOperationHandle {
/** Identity of the live operation controlled by this handle. */
readonly id: number;
/** Key of the source node that created the operation. */
readonly originKey: string;
/** Key of the newly mounted or retained target node. */
readonly targetKey: string;
/**
* 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.
*/
update(progress: number, velocity?: number): void;
/**
* Select commit/cancel, run the settling spring, and finalize the scene.
*
* @returns `true` when the target was retained, otherwise `false`.
*/
finish(options?: OriginFinishOptions): Promise<boolean>;
/** Cancel the operation and remove its target branch. */
cancel(options?: Pick<OriginFinishOptions, "animate">): Promise<void>;
}
/**
* A self-contained scene graph, view-recipe history, and animation compositor.
*
* Multiple scenes may coexist and do not share nodes, history, or operations.
*/
export interface OriginScene {
/** Reactive snapshot of every currently mounted node. */
readonly nodes: ComputedRef<readonly OriginSceneNode[]>;
/** Reactive snapshot of every live animation/gesture operation. */
readonly operations: ComputedRef<readonly OriginOperation[]>;
/** Keys of visible operation-graph roots; parked history is excluded. */
readonly roots: Readonly<ShallowRef<readonly string[]>>;
/** Return the node-local action context for a mounted node key. */
contextFor(nodeKey: string): OriginContext;
/**
* Create a forward target or reveal a retained back target, then return
* manual control of its operation.
*
* @throws If the origin is missing, parked, or already owns an outgoing
* operation.
*/
begin(
originKey: string,
action: OriginAction,
): Promise<OriginOperationHandle>;
/**
* Resolve a target and commit it programmatically using the settling spring.
*
* @returns `true` once the target has been committed.
*/
perform(originKey: string, action: OriginAction): Promise<boolean>;
/** @internal Register or unregister a scene node's host element. */
registerElement(nodeKey: string, element: HTMLElement | null): void;
/** @internal Register or unregister the scene container used for measurement. */
registerContainer(element: HTMLElement | null): void;
/** @internal Calculate the fully composited inline style for one node host. */
styleForNode(nodeKey: string): CSSProperties;
/** @internal Determine whether a node should currently receive pointer input. */
isNodeInteractive(nodeKey: string): boolean;
}
/** Node-scoped controls returned by {@link useOrigin}. */
export interface UseOrigin {
/** Key of the mounted node containing the calling component. */
readonly nodeKey: string;
/** Scene containing the calling component. */
readonly scene: OriginScene;
/** Reactive context for the calling component's scene node. */
readonly context: ComputedRef<OriginContext>;
/** Reactive shorthand for `context.value.view`. */
readonly view: ComputedRef<OriginView>;
/** Reactive shorthand for `context.value.previous`. */
readonly previous: ComputedRef<OriginView | undefined>;
/** Reactive shorthand for `context.value.canGoBack`. */
readonly canGoBack: ComputedRef<boolean>;
/** Begin an interactively controlled operation from this component. */
begin(action: OriginAction): Promise<OriginOperationHandle>;
/** Programmatically create and commit a target from this component. */
perform(action: OriginAction): Promise<boolean>;
}
/**
* 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>;
/** Direction in which pointer movement advances operation progress. */
export type OriginGestureDirection = "left" | "right" | "up" | "down";
/** Physical side of the gesture host used to admit pointer-down. */
export type OriginGestureEdge = "left" | "right" | "top" | "bottom";
/**
* CSS length accepted by an edge-based gesture start rule.
*
* Numbers are interpreted as CSS pixels. Strings may use normal CSS lengths,
* percentages, `calc()`, or `clamp()`, and are resolved against the gesture
* host when pointer-down occurs.
*/
export type OriginGestureDistance = number | string;
/** A pointer position measured in viewport and gesture-host coordinates. */
export interface OriginGesturePoint {
/** Viewport-relative horizontal position. */
readonly clientX: number;
/** Viewport-relative vertical position. */
readonly clientY: number;
/** Horizontal position relative to the gesture host's left edge. */
readonly localX: number;
/** Vertical position relative to the gesture host's top edge. */
readonly localY: number;
}
/** Context supplied to a custom `.from.when()` start predicate. */
export interface OriginGestureStartContext {
/** Native pointer-down event being considered. */
readonly event: PointerEvent;
/** Component instance from which the gesture would originate. */
readonly origin: OriginContext;
/** Element carrying the gesture's pointer handlers. */
readonly host: HTMLElement;
/** Gesture-host bounds captured at pointer-down. */
readonly bounds: OriginRect;
/** Pointer position at pointer-down. */
readonly point: OriginGesturePoint;
}
/**
* Synchronous predicate deciding whether a pointer-down may become a gesture.
*
* Directional recognition still occurs later, after movement passes the
* configured intent threshold.
*/
export type OriginGestureStartPredicate = (
context: OriginGestureStartContext,
) => boolean;
/** Start policy stored in a completed gesture definition. */
export type OriginGestureStart =
| {
/** Recognize pointer-down anywhere on the host. */
readonly kind: "anywhere";
}
| {
/** Recognize pointer-down within a CSS distance of one host side. */
readonly kind: "edge";
readonly edge: OriginGestureEdge;
readonly distance: OriginGestureDistance;
}
| {
/** Recognize pointer-down when application policy returns `true`. */
readonly kind: "when";
readonly predicate: OriginGestureStartPredicate;
};
/** Direction-recognition tuning accepted by `.to.left()` and its siblings. */
export interface OriginGestureDirectionOptions {
/**
* Minimum directed movement in CSS pixels before the gesture captures.
*
* @defaultValue `8`
*/
readonly threshold?: number;
/**
* Ratio by which directed movement must exceed cross-axis movement.
*
* @defaultValue `1.15`
*/
readonly axisDominance?: number;
}
/** Values available when a custom `.complete()` policy runs on pointer-up. */
export interface OriginGestureCompletionContext {
/** Component instance that originated the gesture. */
readonly origin: OriginContext;
/** Recognized movement direction. */
readonly direction: OriginGestureDirection;
/** Normalized directed distance, clamped to `0..1`. */
readonly progress: number;
/** Latest normalized progress units per second. */
readonly velocity: number;
/** Directed movement from pointer-down in CSS pixels. */
readonly distance: number;
/** Absolute cross-axis movement from pointer-down in CSS pixels. */
readonly crossDistance: number;
/** Elapsed time since pointer-down in milliseconds. */
readonly duration: number;
/** Native pointer-up event that ended the interaction. */
readonly event: PointerEvent;
/** Element carrying the gesture's pointer handlers. */
readonly host: HTMLElement;
/** Gesture-host bounds captured at pointer-down. */
readonly bounds: OriginRect;
/** Pointer position captured at pointer-down. */
readonly start: OriginGesturePoint;
/** Pointer position at release. */
readonly current: OriginGesturePoint;
}
/** Synchronous commit/cancel policy installed by `.complete()`. */
export type OriginGestureCompletionPredicate = (
context: OriginGestureCompletionContext,
) => boolean;
/**
* Values accepted from a gesture builder's `.navigate()` factory.
*
* A promise supports lazy view selection. Stale resolutions are discarded if
* the pointer has already ended or been cancelled.
*/
export type MaybeOriginNavigationIntent =
| OriginNavigationIntent
| null
| undefined
| Promise<OriginNavigationIntent | null | undefined>;
/** Factory that resolves navigation after directional recognition succeeds. */
export type OriginGestureNavigationFactory = (
context: OriginContext,
) => MaybeOriginNavigationIntent;
/**
* Immutable, executable result of a complete gesture builder chain.
*
* Pass this object to {@link useOriginGesture}. An omitted `.from` step is
* represented as an `anywhere` start rule.
*/
export interface OriginGestureDefinition {
/** Discriminator used by the compatibility recognizer overload. */
readonly kind: "origin-gesture-definition";
/** Pointer-down eligibility policy. */
readonly start: OriginGestureStart;
/** Direction in which movement advances operation progress. */
readonly direction: OriginGestureDirection;
/** Directional intent recognition tuning. */
readonly recognition: Readonly<OriginGestureDirectionOptions>;
/**
* Optional release decision. When omitted, choreography thresholds decide.
*/
readonly completion?: OriginGestureCompletionPredicate;
/** Node-local destination/history resolver. */
readonly navigation: OriginGestureNavigationFactory;
/** Visual routine paired with the navigation intent. */
readonly choreography: OriginChoreography;
}
/** Direction-selection stage shared by `gesture.to` and `.from.*().to`. */
export interface OriginGestureToBuilder {
/** Recognize leftward pointer movement. */
left(options?: OriginGestureDirectionOptions): OriginGestureDirectedBuilder;
/** Recognize rightward pointer movement. */
right(options?: OriginGestureDirectionOptions): OriginGestureDirectedBuilder;
/** Recognize upward pointer movement. */
up(options?: OriginGestureDirectionOptions): OriginGestureDirectedBuilder;
/** Recognize downward pointer movement. */
down(options?: OriginGestureDirectionOptions): OriginGestureDirectedBuilder;
}
/** Stage produced after a `.from` policy has been selected. */
export interface OriginGestureFromSelection {
/** Select the direction that advances this gesture. */
readonly to: OriginGestureToBuilder;
}
/** Optional pointer-down policy exposed at the start of a gesture chain. */
export interface OriginGestureFromBuilder {
/** Admit pointer-down within `distance` of the host's left edge. */
left(distance: OriginGestureDistance): OriginGestureFromSelection;
/** Admit pointer-down within `distance` of the host's right edge. */
right(distance: OriginGestureDistance): OriginGestureFromSelection;
/** Admit pointer-down within `distance` of the host's top edge. */
top(distance: OriginGestureDistance): OriginGestureFromSelection;
/** Admit pointer-down within `distance` of the host's bottom edge. */
bottom(distance: OriginGestureDistance): OriginGestureFromSelection;
/** Admit pointer-down anywhere on the gesture host. */
anywhere(): OriginGestureFromSelection;
/** Admit pointer-down when a synchronous application predicate succeeds. */
when(predicate: OriginGestureStartPredicate): OriginGestureFromSelection;
}
/** Builder stage after direction is known and completion remains optional. */
export interface OriginGestureDirectedBuilder {
/** Override the choreography's default release decision. */
complete(
predicate: OriginGestureCompletionPredicate,
): OriginGestureCompletedBuilder;
/** Select the target/history mutation while retaining default completion. */
navigate(
factory: OriginGestureNavigationFactory,
): OriginGestureNavigationBuilder;
}
/** Builder stage after a custom completion policy has been selected. */
export interface OriginGestureCompletedBuilder {
/** Select the target/history mutation performed on commit. */
navigate(
factory: OriginGestureNavigationFactory,
): OriginGestureNavigationBuilder;
}
/** Final builder stage waiting for visual choreography. */
export interface OriginGestureNavigationBuilder {
/** Attach visual choreography and produce an executable definition. */
animate(choreography: OriginChoreography): OriginGestureDefinition;
}
/** Root of the immutable fluent gesture-definition API. */
export interface OriginGestureBuilder {
/** Optionally constrain where pointer-down may begin. */
readonly from: OriginGestureFromBuilder;
/**
* Select movement direction with an implicit `from.anywhere()` start.
*/
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.
*/
export interface OriginGestureBinding {
/** Required size and `touch-action` styles for the gesture host. */
readonly style: Readonly<CSSProperties>;
/** Pointer-down handler that records a potentially eligible gesture. */
readonly onPointerdown: (event: PointerEvent) => void;
/** Pointer-move handler that recognizes and updates the operation. */
readonly onPointermove: (event: PointerEvent) => void;
/** Pointer-up handler that commits or cancels using progress and velocity. */
readonly onPointerup: (event: PointerEvent) => void;
/** Pointer-cancel handler that abandons any captured operation. */
readonly onPointercancel: () => void;
}
/** Props shared by both `OriginGesture` declaration styles. */
export interface OriginGestureBaseProps {
/**
* 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. */
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 {
/**
* Native HTML tag rendered as the shared gesture host.
*
* @defaultValue `"div"`
*/
as?: string;
/**
* 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.
*/
gestures: readonly OriginGestureDefinition[];
}
/** Public props accepted by the `OriginScene` renderer component. */
export interface OriginSceneProps {
/** Scene instance whose flat component nodes should be rendered. */
scene: OriginScene;
}
/**
* Injection payload provided by each stable scene-node host.
*
* @internal
*/
export interface OriginNodeScope {
/** Scene containing the node. */
readonly scene: OriginScene;
/** Unique key of the mounted node. */
readonly nodeKey: string;
}
/**
* Mutable operation storage used by the scene implementation.
*
* @internal
*/
export interface MutableOriginOperation {
/** Unique operation identity. */
id: number;
/** Origin node key. */
originKey: string;
/** Newly created or retained target node key. */
targetKey: string;
/** Original history entry targeted before visual-edge rewrites. */
entryTargetKey: string;
/** Visual choreography for this edge. */
choreography: OriginChoreography;
/** Target stacking relationship. */
placement: OriginPlacement;
/** Retained-history behavior selected for this operation. */
history: OriginHistoryMode;
/** Normalized progress. */
progress: number;
/** Normalized velocity. */
velocity: number;
/** Current lifecycle phase. */
phase: OriginOperationPhase;
/** Selected outcome. */
intent: OriginOperationIntent;
/** Measured origin bounds, when an element was available. */
originRect?: OriginRect;
/** Measured target bounds, after the target mounted. */
targetRect?: OriginRect;
}

View File

@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.app.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": true,
"noEmit": false,
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "../../node_modules/.tmp/core-v2.tsbuildinfo"
},
"include": ["src/**/*.ts", "src/**/*.vue"],
"exclude": ["src/**/*.test.ts"]
}

View File

@@ -0,0 +1,20 @@
import { resolve } from "node:path";
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [vue()],
build: {
outDir: "dist",
emptyOutDir: true,
lib: {
entry: resolve(__dirname, "src/index.ts"),
formats: ["es"],
fileName: "index",
cssFileName: "style",
},
// V2 intentionally has no vue-router dependency. Vue is supplied by the
// consuming application so every scene shares the application's renderer.
rollupOptions: { external: ["vue"] },
},
});

View File

@@ -12,6 +12,7 @@
"paths": {
"@/*": ["./apps/demo/src/*"],
"@native-vue-router/core": ["./packages/core/src/index.ts"],
"@native-vue-router/core-v2": ["./packages/core-v2/src/index.ts"],
"@native-vue-router/preset-native": [
"./packages/preset-native/src/index.ts"
],