Increase complexity of test demo

This commit is contained in:
2026-07-31 07:51:50 +00:00
parent 55dad11b25
commit 056f341cd3
16 changed files with 1589 additions and 52 deletions

View File

@@ -153,6 +153,57 @@ export const fall = defineOriginChoreography({
}),
});
/**
* A connected presentation rather than a full-screen route replacement.
*
* At rest the page has moved right by two thirds, leaving its left third
* visible in the exposed rail. `persistAtRest` keeps these progress-1 effects
* connected after commit instead of parking and hiding the page.
*/
export const partialDrawerOpen = defineOriginChoreography({
name: "partial-drawer-open",
persistAtRest: true,
commitThreshold: 0.28,
commitVelocity: 0.68,
effects: ({ progress }) => ({
source: {
transform: `translate3d(${percent(progress * (2 / 3))}, 0, 0)`,
style: {
filter: `brightness(${1 - progress * 0.22})`,
},
},
target: {
transform: `translate3d(${percent((progress - 1) * (2 / 3))}, 0, 0)`,
opacity: 0.35 + progress * 0.65,
style: {
boxShadow: `24px 0 70px rgb(0 0 0 / ${progress * 0.36})`,
},
},
}),
});
/** Reciprocal close motion for the connected two-thirds drawer. */
export const partialDrawerClose = defineOriginChoreography({
name: "partial-drawer-close",
commitThreshold: 0.28,
commitVelocity: 0.68,
effects: ({ progress }) => ({
source: {
transform: `translate3d(${percent(progress * (-2 / 3))}, 0, 0)`,
opacity: 1 - progress * 0.65,
style: {
boxShadow: `24px 0 70px rgb(0 0 0 / ${(1 - progress) * 0.36})`,
},
},
target: {
transform: `translate3d(${percent((1 - progress) * (2 / 3))}, 0, 0)`,
style: {
filter: `brightness(${0.78 + progress * 0.22})`,
},
},
}),
});
/**
* 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

View File

@@ -343,6 +343,14 @@ summary:focus-visible {
--lab-glow: #438cff;
}
.lab-card--teal {
--lab-glow: #4fffd0;
}
.lab-card--orange {
--lab-glow: #ff7b42;
}
.lab-number {
color: rgb(255 255 255 / 42%);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
@@ -401,6 +409,444 @@ summary:focus-visible {
font-size: 0.7rem;
}
/* Replace checkout */
.checkout-view {
width: 100%;
height: 100%;
padding: max(1.25rem, env(safe-area-inset-top))
max(1.25rem, env(safe-area-inset-right))
max(1.25rem, env(safe-area-inset-bottom))
max(1.25rem, env(safe-area-inset-left));
overflow: auto;
background:
radial-gradient(
circle at 15% 20%,
rgb(34 101 100 / 34%),
transparent 32rem
),
linear-gradient(145deg, #07100f, #0d1518 55%, #07100f);
}
.checkout-toolbar {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
width: min(100%, 68rem);
margin: 0 auto;
color: rgb(255 255 255 / 62%);
font-size: 0.76rem;
font-weight: 720;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.checkout-toolbar button {
justify-self: start;
width: 2.5rem;
height: 2.5rem;
border: 1px solid rgb(255 255 255 / 16%);
border-radius: 50%;
background: rgb(255 255 255 / 7%);
}
.checkout-step {
justify-self: end;
}
.checkout-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(20rem, 27rem);
align-items: center;
gap: clamp(2rem, 7vw, 7rem);
width: min(100%, 68rem);
min-height: calc(100% - 3rem);
margin: 0 auto;
padding: 3rem 0;
}
.checkout-copy {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.checkout-copy h1,
.confirmation-card h1 {
margin: 0;
font-size: clamp(3.4rem, 8vw, 7rem);
line-height: 0.9;
letter-spacing: -0.065em;
}
.checkout-copy p,
.confirmation-card p {
max-width: 34rem;
margin: 0 0 0.5rem;
color: rgb(255 255 255 / 61%);
line-height: 1.6;
}
.payment-card {
display: flex;
flex-direction: column;
gap: 1rem;
border: 1px solid rgb(255 255 255 / 14%);
border-radius: 1.8rem;
padding: 1.4rem;
background: rgb(255 255 255 / 7%);
box-shadow: 0 2rem 6rem rgb(0 0 0 / 32%);
backdrop-filter: blur(18px);
}
.payment-card label {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.42rem;
color: rgb(255 255 255 / 48%);
font-size: 0.7rem;
font-weight: 720;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.payment-card input {
width: 100%;
border: 1px solid rgb(255 255 255 / 13%);
border-radius: 0.8rem;
padding: 0.8rem;
color: white;
background: rgb(0 0 0 / 18%);
}
.order-line,
.payment-row,
.card-number {
display: flex;
align-items: center;
gap: 0.75rem;
}
.order-line {
justify-content: space-between;
border-bottom: 1px solid rgb(255 255 255 / 10%);
padding-bottom: 1rem;
}
.order-line strong {
color: #7bffd4;
font-size: 1.3rem;
}
.card-number {
border: 1px solid rgb(255 255 255 / 13%);
border-radius: 0.8rem;
padding-left: 0.8rem;
color: rgb(255 255 255 / 38%);
background: rgb(0 0 0 / 18%);
}
.card-number input {
min-width: 4rem;
border: 0;
padding-left: 0;
background: transparent;
}
.pay-button,
.confirmation-action {
border: 0;
border-radius: 0.9rem;
padding: 0.9rem 1.1rem;
color: #06110d;
background: #70ffd0;
font-weight: 820;
}
.pay-button:disabled {
cursor: wait;
opacity: 0.66;
}
.payment-card > small,
.confirmation-card > small {
align-self: center;
color: rgb(255 255 255 / 40%);
}
.confirmation-view {
display: grid;
place-items: center;
}
.confirmation-card {
display: flex;
width: min(100%, 44rem);
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.confirmation-mark {
display: grid;
width: 4.5rem;
height: 4.5rem;
place-items: center;
border-radius: 50%;
color: #06110d;
background: #70ffd0;
box-shadow: 0 0 0 0.8rem rgb(112 255 208 / 9%);
font-size: 2rem;
font-weight: 900;
}
.confirmation-card code {
color: #8edfff;
}
.confirmation-card dl {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
width: 100%;
margin: 0.5rem 0;
border: 1px solid rgb(255 255 255 / 13%);
border-radius: 1rem;
overflow: hidden;
}
.confirmation-card dl > div {
padding: 0.9rem;
background: rgb(255 255 255 / 5%);
}
.confirmation-card dl > div + div {
border-left: 1px solid rgb(255 255 255 / 10%);
}
.confirmation-card dt {
color: rgb(255 255 255 / 42%);
font-size: 0.64rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.confirmation-card dd {
margin: 0.3rem 0 0;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
/* Connected partial drawer */
.partial-origin-view {
display: grid;
grid-template-columns: 1fr 2fr;
width: 100%;
height: 100%;
background: #10131a;
}
.partial-origin-rail {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2rem;
padding: max(1.5rem, env(safe-area-inset-top)) clamp(0.8rem, 3vw, 2rem)
max(1.5rem, env(safe-area-inset-bottom));
background:
radial-gradient(
circle at 20% 15%,
rgb(255 132 74 / 30%),
transparent 18rem
),
#19151a;
}
.partial-brand {
display: grid;
width: 3rem;
height: 3rem;
place-items: center;
border-radius: 0.9rem;
color: #261006;
background: #ff8c57;
font-weight: 900;
}
.partial-origin-rail nav {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.partial-origin-rail nav button {
border: 0;
border-radius: 0.7rem;
padding: 0.75rem;
color: rgb(255 255 255 / 62%);
text-align: left;
background: transparent;
}
.partial-origin-rail nav button:first-child {
color: white;
background: rgb(255 255 255 / 9%);
}
.partial-origin-rail .instance-card {
grid-template-columns: minmax(0, 1fr);
width: 100%;
min-width: 0;
margin-top: auto;
}
.partial-origin-rail .instance-card strong {
grid-row: auto;
grid-column: auto;
}
.partial-origin-content {
display: flex;
min-width: 0;
flex-direction: column;
padding: max(1.5rem, env(safe-area-inset-top))
max(1.5rem, env(safe-area-inset-right))
max(1.5rem, env(safe-area-inset-bottom)) clamp(1.5rem, 5vw, 5rem);
background:
linear-gradient(rgb(255 255 255 / 3%) 1px, transparent 1px),
linear-gradient(90deg, rgb(255 255 255 / 3%) 1px, transparent 1px), #0b0e14;
background-size: 48px 48px;
}
.partial-origin-content > header,
.partial-drawer-panel > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.partial-origin-content > header button,
.partial-drawer-panel > header button {
border: 1px solid rgb(255 255 255 / 14%);
border-radius: 999px;
padding: 0.55rem 0.8rem;
color: white;
background: rgb(255 255 255 / 7%);
}
.partial-origin-content > header span {
color: rgb(255 255 255 / 38%);
font-size: 0.66rem;
font-weight: 750;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.partial-origin-copy {
display: flex;
max-width: 48rem;
flex: 1;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 1rem;
}
.partial-origin-copy h1,
.partial-drawer-panel h1 {
margin: 0;
font-size: clamp(3.5rem, 8vw, 7rem);
line-height: 0.9;
letter-spacing: -0.065em;
}
.partial-origin-copy p,
.partial-drawer-panel > p {
max-width: 38rem;
margin: 0;
color: rgb(255 255 255 / 62%);
line-height: 1.6;
}
.partial-drawer-layer {
display: grid;
grid-template-columns: 2fr 1fr;
width: 100%;
height: 100%;
}
.partial-drawer-panel {
z-index: 1;
display: flex;
min-width: 0;
flex-direction: column;
gap: 1.4rem;
padding: max(1.5rem, env(safe-area-inset-top)) clamp(1.2rem, 5vw, 4rem)
max(1.5rem, env(safe-area-inset-bottom))
max(1.2rem, env(safe-area-inset-left));
background:
radial-gradient(
circle at 12% 12%,
rgb(255 141 88 / 28%),
transparent 25rem
),
#18151b;
box-shadow: 24px 0 80px rgb(0 0 0 / 35%);
}
.partial-drawer-panel > header > div {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.partial-drawer-panel > header button {
width: 2.8rem;
height: 2.8rem;
padding: 0;
font-size: 1.5rem;
}
.partial-drawer-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: auto 0;
}
.partial-drawer-list button {
display: flex;
align-items: center;
gap: 1rem;
border: 1px solid rgb(255 255 255 / 10%);
border-radius: 1rem;
padding: 1rem;
color: white;
text-align: left;
background: rgb(255 255 255 / 5%);
}
.partial-drawer-list button span {
color: #ff9b6c;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.7rem;
}
.partial-drawer-panel > small {
color: rgb(255 255 255 / 38%);
}
.partial-drawer-backdrop {
width: 100%;
height: 100%;
border: 0;
padding: 0;
background: rgb(0 0 0 / 18%);
cursor: w-resize;
}
/* Scene inspector */
.scene-debug {
@@ -501,6 +947,10 @@ summary:focus-visible {
color: #8edfff;
}
.node-state--exposed {
color: #ffad7f;
}
.debug-content code {
color: #8edfff;
}
@@ -1949,6 +2399,78 @@ summary:focus-visible {
.nested-page-gesture {
display: none;
}
.checkout-layout {
grid-template-columns: 1fr;
align-items: start;
padding-top: 4rem;
}
.checkout-copy h1,
.confirmation-card h1 {
font-size: clamp(3.2rem, 16vw, 5.4rem);
}
.checkout-step {
display: none;
}
.checkout-toolbar {
grid-template-columns: 1fr auto;
}
.confirmation-card dl {
grid-template-columns: 1fr;
}
.confirmation-card dl > div + div {
border-top: 1px solid rgb(255 255 255 / 10%);
border-left: 0;
}
.partial-origin-rail {
gap: 1rem;
padding-right: 0.6rem;
padding-left: 0.6rem;
}
.partial-origin-rail nav button {
padding: 0.55rem 0.35rem;
font-size: 0.66rem;
}
.partial-origin-rail .instance-card {
display: none;
}
.partial-origin-content {
padding-left: 1.25rem;
}
.partial-origin-copy h1,
.partial-drawer-panel h1 {
font-size: clamp(2.8rem, 13vw, 4.8rem);
}
.partial-drawer-panel {
padding-right: 1rem;
padding-left: 1rem;
}
.partial-drawer-panel > header {
position: relative;
padding-right: 2.8rem;
}
.partial-drawer-panel > header button {
position: absolute;
top: 0;
right: 0;
}
.partial-drawer-panel h1 {
font-size: clamp(2.15rem, 10vw, 3.25rem);
}
}
@media (max-height: 700px) {

View File

@@ -31,13 +31,11 @@ const createSecond = () => above(secondView(), slideLeft);
const goBack = (context: OriginContext) =>
context.canGoBack ? back(slideRight) : null;
const forwardGesture = gesture.to
.left()
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);

View File

@@ -17,6 +17,8 @@ import EdgePredicateView from "./EdgePredicateView.vue";
import FirstView from "./FirstView.vue";
import GalleryView from "./GalleryView.vue";
import NestedScenesView from "./NestedScenesView.vue";
import PaymentDetailsView from "./PaymentDetailsView.vue";
import PartialDrawerPageView from "./PartialDrawerPageView.vue";
import PlayerView from "./PlayerView.vue";
const origin = useOrigin();
@@ -60,6 +62,10 @@ const openPredicate = () =>
actionFor(EdgePredicateView, "Predicate Edge", focusPortal);
const openNestedScenes = () =>
actionFor(NestedScenesView, "Nested Scenes", focusPortal);
const openCheckout = () =>
actionFor(PaymentDetailsView, "Payment Details", slideLeft);
const openPartialDrawer = () =>
actionFor(PartialDrawerPageView, "Partial Drawer", focusPortal);
const labs = [
{
@@ -123,6 +129,24 @@ const labs = [
action: openNestedScenes,
accent: "blue",
},
{
number: "08",
title: "Replace checkout",
description:
"Confirm a mock payment, replace its mounted entry, then test where back returns.",
tags: ["atomic replace", "history rewrite"],
action: openCheckout,
accent: "teal",
},
{
number: "09",
title: "Partial drawer",
description:
"Push a connected drawer two-thirds across while the live source occupies the final third.",
tags: ["persistent effects", "partial view"],
action: openPartialDrawer,
accent: "orange",
},
] as const;
</script>

View File

@@ -0,0 +1,74 @@
<script setup lang="ts">
import {
OriginGesture,
back,
gesture,
slideRight,
useOrigin,
} from "@native-vue-router/core-v2";
import InstanceCard from "../components/InstanceCard.vue";
import { useDemoInstance } from "../lab-state";
defineProps<{
orderReference: string;
replacedInstance: string;
}>();
const origin = useOrigin();
const identity = useDemoInstance("Confirmation");
const returnGesture = gesture.from
.left("clamp(24px, 8%, 64px)")
.to.right()
.navigate((context) => (context.canGoBack ? back() : null))
.animate(slideRight);
function returnToLabs() {
if (origin.context.value.canGoBack) void origin.perform(back(slideRight));
}
</script>
<template>
<OriginGesture
class="checkout-view confirmation-view"
:gesture="returnGesture"
>
<section class="confirmation-card">
<div class="confirmation-mark" aria-hidden="true"></div>
<div class="eyebrow">Payment complete</div>
<h1>Order confirmed.</h1>
<p>
The Payment Details node <code>{{ replacedInstance }}</code> has been
removed. Back now resolves the retained Hub instance directly.
</p>
<dl>
<div>
<dt>Order</dt>
<dd>{{ orderReference }}</dd>
</div>
<div>
<dt>History depth</dt>
<dd>{{ origin.context.value.history.length }}</dd>
</div>
<div>
<dt>Previous view</dt>
<dd>{{ origin.context.value.previous?.name ?? "None" }}</dd>
</div>
</dl>
<InstanceCard
label="Confirmation instance"
:instance="identity.instance"
:status="identity.status.value"
/>
<button
class="confirmation-action"
type="button"
data-origin-gesture="ignore"
@click="returnToLabs"
>
Back to labs
</button>
<small>Try the left-edge back gesture too.</small>
</section>
</OriginGesture>
</template>

View File

@@ -0,0 +1,99 @@
<script setup lang="ts">
import {
OriginGestureSurface,
back,
forward,
gesture,
originView,
slideRight,
useOrigin,
} from "@native-vue-router/core-v2";
import InstanceCard from "../components/InstanceCard.vue";
import { useDemoInstance } from "../lab-state";
import { partialDrawerOpen } from "../motions";
import PartialDrawerView from "./PartialDrawerView.vue";
const origin = useOrigin();
const identity = useDemoInstance("Drawer page");
const drawerView = () =>
originView(
PartialDrawerView,
{ openedFrom: identity.instance },
{ key: "partial-drawer", name: "Two-thirds Drawer" },
);
const openDrawerGesture = gesture.from
.left("clamp(32px, 10%, 88px)")
.to.right()
.navigate(() => forward(drawerView()))
.animate(partialDrawerOpen);
const gestures = [openDrawerGesture] as const;
function openDrawer() {
void origin.perform(forward(drawerView(), partialDrawerOpen));
}
function returnToLabs() {
if (origin.context.value.canGoBack) void origin.perform(back(slideRight));
}
</script>
<template>
<OriginGestureSurface
as="main"
class="partial-origin-view"
:gestures="gestures"
>
<!--
This rail is the left third of the full page. Once the whole page moves
right by 2/3, this exact live DOM remains visible in the final third.
-->
<aside class="partial-origin-rail">
<div class="partial-brand">OV</div>
<nav aria-label="Example navigation">
<button type="button" data-origin-gesture="ignore">Inbox</button>
<button type="button" data-origin-gesture="ignore">Projects</button>
<button type="button" data-origin-gesture="ignore">Archive</button>
</nav>
<InstanceCard
label="Page instance"
:instance="identity.instance"
:status="identity.status.value"
/>
</aside>
<section class="partial-origin-content">
<header>
<button
type="button"
data-origin-gesture="ignore"
aria-label="Return to labs"
@click="returnToLabs"
>
Labs
</button>
<span>Connected presentation</span>
</header>
<div class="partial-origin-copy">
<div class="eyebrow">Partial route lab</div>
<h1>Keep one third of this page on screen.</h1>
<p>
Drag right from the left edge. The drawer becomes a real history
entry, while this same mounted page remains translated and visible.
</p>
<button
class="primary-action"
type="button"
data-origin-gesture="ignore"
@click="openDrawer"
>
Open two-thirds drawer
</button>
<div class="gesture-hint"><span></span> Pull from the left edge</div>
</div>
</section>
</OriginGestureSurface>
</template>

View File

@@ -0,0 +1,84 @@
<script setup lang="ts">
import {
OriginGesture,
back,
gesture,
useOrigin,
} from "@native-vue-router/core-v2";
import InstanceCard from "../components/InstanceCard.vue";
import { useDemoInstance } from "../lab-state";
import { partialDrawerClose } from "../motions";
defineProps<{ openedFrom: string }>();
const origin = useOrigin();
const identity = useDemoInstance("Drawer");
const closeGesture = gesture.to
.left()
.navigate((context) => (context.canGoBack ? back() : null))
.animate(partialDrawerClose);
function closeDrawer() {
if (origin.context.value.canGoBack)
void origin.perform(back(partialDrawerClose));
}
</script>
<template>
<OriginGesture class="partial-drawer-layer" :gesture="closeGesture">
<aside class="partial-drawer-panel">
<header>
<div>
<div class="eyebrow">Two-thirds destination</div>
<h1>Workspace</h1>
</div>
<button
type="button"
data-origin-gesture="ignore"
aria-label="Close drawer"
@click="closeDrawer"
>
×
</button>
</header>
<p>
This drawer is its own Vue view and history entry. It did not copy,
teleport, or remount the page visible beside it.
</p>
<div class="partial-drawer-list">
<button type="button" data-origin-gesture="ignore">
<span>01</span>
Recent activity
</button>
<button type="button" data-origin-gesture="ignore">
<span>02</span>
Shared with me
</button>
<button type="button" data-origin-gesture="ignore">
<span>03</span>
Offline files
</button>
</div>
<InstanceCard
label="Drawer instance"
:instance="identity.instance"
:status="identity.status.value"
/>
<small>
Opened over {{ openedFrom }} · swipe left or tap the exposed third to
close.
</small>
</aside>
<button
class="partial-drawer-backdrop"
type="button"
aria-label="Close drawer from exposed page"
@click="closeDrawer"
/>
</OriginGesture>
</template>

View File

@@ -0,0 +1,156 @@
<script setup lang="ts">
import { ref } from "vue";
import {
OriginGestureSurface,
back,
gesture,
originView,
replace,
slideLeft,
slideRight,
useOrigin,
} from "@native-vue-router/core-v2";
import InstanceCard from "../components/InstanceCard.vue";
import { useDemoInstance } from "../lab-state";
import OrderConfirmationView from "./OrderConfirmationView.vue";
const origin = useOrigin();
const identity = useDemoInstance("Payment");
const cardholder = ref("Ada Lovelace");
const lastFour = ref("4242");
const processing = ref(false);
function confirmationView() {
return originView(
OrderConfirmationView,
{
orderReference: `NVO-${Math.floor(Date.now() / 1000)
.toString()
.slice(-6)}`,
replacedInstance: identity.instance,
},
{ key: "order-confirmation", name: "Order Confirmation" },
);
}
const confirmGesture = gesture.to
.left()
.navigate(() => replace(confirmationView()))
.animate(slideLeft);
const backGesture = gesture.from
.left("clamp(24px, 8%, 64px)")
.to.right()
.navigate((context) => (context.canGoBack ? back() : null))
.animate(slideRight);
const gestures = [confirmGesture, backGesture] as const;
function returnToLabs() {
if (origin.context.value.canGoBack) void origin.perform(back(slideRight));
}
async function pay() {
if (processing.value) return;
processing.value = true;
// `replace` is one atomic scene operation. Payment Details stays mounted
// throughout the animation and is removed only after the commit completes.
await origin.perform(replace(confirmationView(), slideLeft));
}
</script>
<template>
<OriginGestureSurface
as="main"
class="checkout-view payment-view"
:gestures="gestures"
>
<header class="checkout-toolbar">
<button
type="button"
data-origin-gesture="ignore"
aria-label="Return to labs"
@click="returnToLabs"
>
</button>
<span>Secure checkout</span>
<span class="checkout-step">Step 2 of 2</span>
</header>
<section class="checkout-layout">
<div class="checkout-copy">
<div class="eyebrow">Replace-history lab</div>
<h1>Payment details</h1>
<p>
Completing this mock payment replaces this mounted entry. The
confirmation page will point straight back to the Hub.
</p>
<InstanceCard
label="Payment instance"
:instance="identity.instance"
:status="identity.status.value"
/>
</div>
<form class="payment-card" @submit.prevent="pay">
<div class="order-line">
<span>Origin Pro</span>
<strong>$48.00</strong>
</div>
<label>
Cardholder
<input
v-model="cardholder"
data-origin-gesture="ignore"
autocomplete="cc-name"
/>
</label>
<label>
Card number
<span class="card-number">
<b> </b>
<input
v-model="lastFour"
data-origin-gesture="ignore"
inputmode="numeric"
maxlength="4"
autocomplete="cc-number"
/>
</span>
</label>
<div class="payment-row">
<label>
Expiry
<input
data-origin-gesture="ignore"
value="08 / 29"
autocomplete="cc-exp"
/>
</label>
<label>
CVC
<input
data-origin-gesture="ignore"
value="123"
maxlength="4"
autocomplete="cc-csc"
/>
</label>
</div>
<button
class="pay-button"
type="submit"
data-origin-gesture="ignore"
:disabled="processing"
>
{{ processing ? "Confirming…" : "Pay $48.00" }}
</button>
<small>Or swipe left anywhere outside the form controls.</small>
</form>
</section>
</OriginGestureSurface>
</template>

View File

@@ -63,6 +63,40 @@ 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.
Committing a replace from Y to Z creates Z but links it directly to Y's
previous entry:
```text
Before: X (parked) ← Y (visible)
After: X (parked) ← Z (visible)
```
Y remains mounted while the operation is interactive and is removed only on
commit. Cancelling removes Z and restores Y, making replacement one atomic
operation rather than a visible back followed by a forward push.
### Connected partial presentations
Most committed pushes collapse their temporary operation edge and park the
retained source. A choreography with `persistAtRest: true` instead keeps its
progress-`1` effects as a settled coordinate relationship:
```text
Page (exposed, inert) → Drawer (active)
```
This supports partial destinations without copying or remounting either Vue
component. For a two-thirds drawer, the settled source effect translates the
page right by two thirds, while the target finishes at its identity position.
The page's left third therefore remains physically visible in the final third
of the viewport.
Settled relationships are not live operations and are omitted from the public
operation diagnostics. Beginning back temporarily suspends the relationship
and lets a reciprocal close choreography start at the same endpoints. A
cancelled close restores the settled edge; a committed close removes the drawer
and returns the page to its normal root position.
## Interaction
Gesture recognition is declared within each component through
@@ -101,5 +135,6 @@ 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>`.
current entry is successfully replaced, or the whole scene is destroyed.
Because the same DOM survives parking, nested scroll positions and
component-local state survive without `<KeepAlive>`.

View File

@@ -305,10 +305,37 @@ again and leaves the current entry active.
`OriginNavigationActionOptions.placement` defaults to `"under"`.
### `replace(target, choreography?, options?)`
Creates a new target while removing the current history entry:
```ts
const confirmOrder = () =>
replace(
originView(OrderConfirmationView, { orderId: "NVO-2048" }),
slideLeft,
);
```
The target inherits the origin's `previousNodeKey`, so a later back skips the
replaced entry. The origin remains mounted while the operation is interactive
or settling and is unmounted only after commit. Cancelling removes the proposed
target and restores the origin without changing history.
Without choreography, `replace(target)` creates an intent suitable for a
gesture builder:
```ts
.navigate(() => replace(originView(OrderConfirmationView)))
.animate(slideLeft)
```
`OriginNavigationActionOptions.placement` defaults to `"above"`.
### `originAction(target, choreography, options?)`
Constructs a complete low-level `OriginAction`. Prefer `forward()` and `back()`
when expressing retained navigation.
Constructs a complete low-level `OriginAction`. Prefer `forward()`, `replace()`,
and `back()` when expressing retained navigation.
```ts
const action = originAction(profile, slideLeft, {
@@ -353,8 +380,9 @@ Omitting choreography returns a navigation intent for a gesture builder.
History is a linked chain of mounted scene nodes.
| Mode | Commit behavior |
| -------- | -------------------------------------------------------------- |
| ----------- | -------------------------------------------------------------- |
| `"push"` | Park and retain the origin; activate the new target |
| `"replace"` | Create a new target, inherit prior history, unmount the origin |
| `"back"` | Reuse the retained previous target; pop and unmount the origin |
Parked entries are `inert`, `aria-hidden`, invisible, and excluded from pointer
@@ -476,6 +504,34 @@ const scaleIn = defineOriginChoreography({
The function returns the same object. Its value is type checking and a clear
construction point.
Set `persistAtRest: true` when progress `1` should remain as a connected visual
relationship after a committed push:
```ts
const openPartialDrawer = defineOriginChoreography({
name: "partial-drawer-open",
persistAtRest: true,
effects: ({ progress }) => ({
source: {
transform: `translateX(${progress * 66.6667}%)`,
},
target: {
transform: `translateX(${(progress - 1) * 66.6667}%)`,
},
}),
});
```
This leaves the retained source mounted, visible, and inert instead of parking
it. The target remains the active history entry. Beginning back suspends the
resting relationship so a reciprocal close choreography can take over;
cancelling back restores it exactly.
Connected resting effects are supported only by retained-history push actions.
They are designed for partial drawers, inspectors, and other presentations
where both mounted views remain visible after commit. They do not appear in
`scene.operations`, which reports live interactive/settling edges only.
`effects()` may return:
| Effect | Applied to |
@@ -566,9 +622,9 @@ 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`.
For forward or replace, mounts a new target and waits one Vue tick for
measurement. For back, reveals and measures the retained previous node. It
then returns an `OriginOperationHandle`.
```ts
const handle = await scene.begin(nodeKey, action);
@@ -624,9 +680,9 @@ 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.
`forward()`, `replace()`, `back()`, `above()`, or `under()` when choreography
is omitted. Gesture `.animate()` combines it with choreography to create the
internal action.
### Gesture definition types
@@ -688,7 +744,7 @@ Rect values are viewport CSS pixels.
- `OriginSceneNode`: mounted identity, retained previous key, state, recipe,
history, and incoming edge.
- `OriginSceneNodeState`: `active`, `transitioning`, or `parked`.
- `OriginSceneNodeState`: `active`, `transitioning`, `exposed`, or `parked`.
- `OriginOperation`: read-only live edge state.
- `OriginOperationPhase`: operation lifecycle phase.
- `OriginOperationIntent`: selected operation outcome.
@@ -705,7 +761,8 @@ implementation types. They are exported by the current barrel but marked
- `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.
- Parked or exposed retained entries are inert and cannot originate operations.
The active connected target owns interactions until back reveals its source.
- A target can originate its own operation as soon as its incoming operation's
intent becomes commit.
- The included recognizer follows one primary pointer and one axis.

View File

@@ -21,16 +21,20 @@ From the workspace root:
npm run dev:v2
```
Open the printed URL to explore seven physical labs:
Open the printed URL to explore nine physical labs:
- a four-view chain that can keep four nodes and three edges live at once;
- one view with horizontal, vertical, and edge-only declarations;
- programmatic gallery navigation followed by gesture-owned traversal;
- a vertically presented media player with local interactive state;
- a chat that intentionally declares no back gesture;
- a predicate-gated downward gesture that drops a left-edge dialog.
- 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.
an intentional parent/child gesture conflict; and
- a checkout flow that replaces Payment Details with Confirmation and proves
that back returns directly to the retained Hub instance; and
- a connected two-thirds drawer that keeps the translated source page visible
in the exposed final third.
The expandable inspector reports mounted Vue instances, active operation
edges, animation progress, and recent lifecycle events. In the chain lab,
@@ -156,6 +160,46 @@ The application chooses whether this is exposed as a left-edge gesture,
toolbar button, keyboard shortcut, Android hardware-back action, or not exposed
at all.
## Replacing the current entry
Use `replace()` for completed one-way flows such as Payment Details →
Confirmation:
```ts
import {
originView,
replace,
slideLeft,
useOrigin,
} from "@native-vue-router/core-v2";
import OrderConfirmationView from "./OrderConfirmationView.vue";
const origin = useOrigin();
function confirmOrder() {
return origin.perform(
replace(
originView(OrderConfirmationView, { orderId: "NVO-2048" }),
slideLeft,
),
);
}
```
The replacement inherits the current entry's mounted predecessor. It does not
retain the entry being replaced, so back from Confirmation skips Payment
Details. The mutation is atomic: a cancelled interactive replacement removes
the proposed Confirmation and restores Payment Details unchanged.
Inside a gesture builder, omit choreography from the intent:
```ts
gesture.to
.left()
.navigate(() => replace(originView(OrderConfirmationView)))
.animate(slideLeft);
```
## Custom choreography
A choreography returns independent effects for its source, target, and their
@@ -195,6 +239,23 @@ Y transform = (X→Y target) × (Y→Z source)
Z transform = (X→Y target) × (Y→Z target)
```
For partial presentations that must keep both views visible after commit, set
`persistAtRest: true` on the opening choreography:
```ts
const openDrawer = defineOriginChoreography({
persistAtRest: true,
effects: ({ progress }) => ({
source: { transform: `translateX(${progress * 66.6667}%)` },
target: { transform: `translateX(${(progress - 1) * 66.6667}%)` },
}),
});
```
The retained source becomes visible-but-inert rather than parked. A reciprocal
back choreography closes the target; a cancelled close restores the connected
resting effects and both original Vue instances.
## Why scene nodes are flat
The operation graph is not represented as Vue component ancestry. Every

View File

@@ -3,7 +3,7 @@ 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 { back, defineOriginChoreography, forward, replace } from "./motion";
import { createOriginScene, originView } from "./scene";
import type {
OriginGestureBinding,
@@ -109,6 +109,11 @@ describe("gesture builder", () => {
placement: "under",
history: "back",
});
expect(replace(target)).toEqual({
target,
placement: "above",
history: "replace",
});
expect(forward(target, testMotion)).toMatchObject({
target,
choreography: testMotion,
@@ -118,6 +123,11 @@ describe("gesture builder", () => {
choreography: testMotion,
history: "back",
});
expect(replace(target, testMotion)).toMatchObject({
target,
choreography: testMotion,
history: "replace",
});
});
it("treats a chain beginning at .to as an immutable anywhere gesture", () => {

View File

@@ -56,7 +56,8 @@ export interface OriginNavigationActionOptions {
/**
* Target stacking relationship during the operation.
*
* @defaultValue `"above"` for {@link forward}, `"under"` for {@link back}
* @defaultValue `"above"` for {@link forward} and {@link replace}, `"under"`
* for {@link back}
*/
placement?: OriginPlacement;
}
@@ -135,6 +136,50 @@ export function forward(
};
}
/**
* Create a retained-history replacement intent or complete action.
*
* The target inherits the origin's previous mounted entry instead of retaining
* the origin itself. A commit unmounts the origin and activates the target, so
* a later {@link back} skips the replaced entry. A cancellation removes the
* proposed target and restores the origin unchanged.
*
* 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 for the replacement.
* @param options - Stacking options for an animation-free navigation intent.
* @returns An animation-free replacement intent for use with a gesture builder.
*/
export function replace(
target: OriginView,
options?: OriginNavigationActionOptions,
): OriginNavigationIntent;
export function replace(
target: OriginView,
choreography: OriginChoreography,
options?: OriginNavigationActionOptions,
): OriginAction;
export function replace(
target: OriginView,
choreographyOrOptions:
OriginChoreography | OriginNavigationActionOptions = {},
options: OriginNavigationActionOptions = {},
): OriginAction | OriginNavigationIntent {
if (isChoreography(choreographyOrOptions)) {
return originAction(target, choreographyOrOptions, {
placement: options.placement ?? "above",
history: "replace",
});
}
return {
target,
placement: choreographyOrOptions.placement ?? "above",
history: "replace",
};
}
/**
* Create a retained-history back intent or complete action.
*

View File

@@ -14,6 +14,7 @@ import {
back,
defineOriginChoreography,
forward,
replace,
under,
} from "./motion";
import { createOriginScene, originView } from "./scene";
@@ -40,6 +41,23 @@ const layeredMotion = defineOriginChoreography({
}),
});
const partialDrawerOpen = defineOriginChoreography({
name: "test-partial-drawer-open",
persistAtRest: true,
effects: ({ progress }) => ({
source: { transform: `translateX(${progress * 66.6667}%)` },
target: { transform: `translateX(${(progress - 1) * 66.6667}%)` },
}),
});
const partialDrawerClose = defineOriginChoreography({
name: "test-partial-drawer-close",
effects: ({ progress }) => ({
source: { transform: `translateX(${-progress * 66.6667}%)` },
target: { transform: `translateX(${(1 - progress) * 66.6667}%)` },
}),
});
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" });
@@ -290,6 +308,215 @@ describe("origin-relative scene graph", () => {
]);
});
it("replaces the current entry and makes back skip the removed instance", async () => {
let paymentUnmounts = 0;
const home = originView(component("Home"), undefined, { key: "home" });
const Payment = defineComponent({
name: "Payment",
setup() {
onUnmounted(() => {
paymentUnmounts += 1;
});
return () => h("div", "Payment");
},
});
const payment = originView(Payment, undefined, { key: "payment" });
const confirmation = originView(component("Confirmation"), undefined, {
key: "confirmation",
});
const scene = createOriginScene({ initial: home });
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 homeKey = scene.nodes.value[0]!.key;
const openPayment = await scene.begin(
homeKey,
forward(payment, layeredMotion),
);
await openPayment.finish({ commit: true, animate: false });
const confirmPayment = await scene.begin(
openPayment.targetKey,
replace(confirmation, layeredMotion),
);
await confirmPayment.finish({ commit: true, animate: false });
await nextTick();
expect(
scene.nodes.value.map((node) => [node.view.name, node.state]),
).toEqual([
["Home", "parked"],
["Confirmation", "active"],
]);
expect(paymentUnmounts).toBe(1);
const confirmationContext = scene.contextFor(confirmPayment.targetKey);
expect(confirmationContext.previous).toBe(home);
expect(confirmationContext.history).toEqual([home]);
const returnHome = await scene.begin(
confirmPayment.targetKey,
back(layeredMotion),
);
expect(returnHome.targetKey).toBe(homeKey);
await returnHome.finish({ commit: true, animate: false });
expect(scene.nodes.value.map((node) => node.view.name)).toEqual(["Home"]);
});
it("restores the current entry when an interactive replace is cancelled", async () => {
const home = originView(component("Home"), undefined, { key: "home" });
const payment = originView(component("Payment"), undefined, {
key: "payment",
});
const confirmation = originView(component("Confirmation"), undefined, {
key: "confirmation",
});
const scene = createOriginScene({ initial: home });
const homeKey = scene.nodes.value[0]!.key;
const openPayment = await scene.begin(
homeKey,
forward(payment, layeredMotion),
);
await openPayment.finish({ commit: true, animate: false });
const confirmPayment = await scene.begin(
openPayment.targetKey,
replace(confirmation, layeredMotion),
);
await confirmPayment.cancel({ animate: false });
expect(
scene.nodes.value.map((node) => [node.view.name, node.state]),
).toEqual([
["Home", "parked"],
["Payment", "active"],
]);
expect(scene.contextFor(openPayment.targetKey).history).toEqual([home]);
});
it("can replace an initial root without manufacturing history", async () => {
const scene = createOriginScene({
initial: originView(component("Welcome"), undefined, { key: "welcome" }),
});
const welcomeKey = scene.nodes.value[0]!.key;
const replacement = await scene.begin(
welcomeKey,
replace(
originView(component("Signed in"), undefined, { key: "signed-in" }),
layeredMotion,
),
);
await replacement.finish({ commit: true, animate: false });
expect(scene.roots.value).toEqual([replacement.targetKey]);
expect(scene.nodes.value.map((node) => node.view.name)).toEqual([
"Signed in",
]);
expect(scene.contextFor(replacement.targetKey)).toMatchObject({
canGoBack: false,
history: [],
previous: undefined,
});
});
it("can replace a target while its incoming push is still settling", async () => {
const home = originView(component("Home"), undefined, { key: "home" });
const payment = originView(component("Payment"), undefined, {
key: "payment",
});
const confirmation = originView(component("Confirmation"), undefined, {
key: "confirmation",
});
const scene = createOriginScene({ initial: home });
const homeKey = scene.nodes.value[0]!.key;
const openPayment = await scene.begin(
homeKey,
forward(payment, layeredMotion),
);
openPayment.update(0.8, 1);
const paymentSettlement = openPayment.finish({ commit: true });
const confirmPayment = await scene.begin(
openPayment.targetKey,
replace(confirmation, layeredMotion),
);
await confirmPayment.finish({ commit: true, animate: false });
await paymentSettlement;
expect(scene.operations.value).toHaveLength(0);
expect(
scene.nodes.value.map((node) => [node.view.name, node.state]),
).toEqual([
["Home", "parked"],
["Confirmation", "active"],
]);
expect(scene.contextFor(confirmPayment.targetKey).history).toEqual([home]);
});
it("keeps a partial push connected at rest and restores it after cancelled back", async () => {
const page = originView(component("Page"), undefined, { key: "page" });
const drawer = originView(component("Drawer"), undefined, {
key: "drawer",
});
const scene = createOriginScene({ initial: page });
const pageKey = scene.nodes.value[0]!.key;
const openDrawer = await scene.begin(
pageKey,
forward(drawer, partialDrawerOpen),
);
await openDrawer.finish({ commit: true, animate: false });
expect(scene.operations.value).toHaveLength(0);
expect(scene.roots.value).toEqual([pageKey]);
expect(
scene.nodes.value.map((node) => [node.view.name, node.state]),
).toEqual([
["Page", "exposed"],
["Drawer", "active"],
]);
expect(scene.styleForNode(pageKey).transform).toBe("translateX(66.6667%)");
expect(scene.styleForNode(pageKey).pointerEvents).toBe("none");
expect(scene.styleForNode(openDrawer.targetKey).transform).toBe(
"translateX(0%)",
);
const closeDrawer = await scene.begin(
openDrawer.targetKey,
back(partialDrawerClose),
);
expect(scene.styleForNode(pageKey).transform).toBe("translateX(66.6667%)");
await closeDrawer.cancel({ animate: false });
expect(scene.operations.value).toHaveLength(0);
expect(scene.roots.value).toEqual([pageKey]);
expect(
scene.nodes.value.map((node) => [node.view.name, node.state]),
).toEqual([
["Page", "exposed"],
["Drawer", "active"],
]);
expect(scene.styleForNode(pageKey).transform).toBe("translateX(66.6667%)");
const committedClose = await scene.begin(
openDrawer.targetKey,
back(partialDrawerClose),
);
await committedClose.finish({ commit: true, animate: false });
expect(scene.nodes.value.map((node) => [node.key, node.state])).toEqual([
[pageKey, "active"],
]);
expect(scene.roots.value).toEqual([pageKey]);
expect(scene.styleForNode(pageKey).transform).toBe("none");
});
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" });

View File

@@ -155,6 +155,12 @@ export function createOriginScene(
): OriginScene {
const nodes = shallowReactive(new Map<string, SceneNodeState>());
const operations = shallowReactive(new Map<number, MutableOriginOperation>());
/*
* A close gesture temporarily removes a connected resting edge to avoid a
* visual cycle (page→drawer→page). If that close is cancelled, this map lets
* us restore the exact same edge and component instances.
*/
const suspendedPresentations = new Map<number, MutableOriginOperation>();
const roots = shallowRef<string[]>([]);
const elements = new Map<string, HTMLElement>();
let container: HTMLElement | null = null;
@@ -222,6 +228,10 @@ export function createOriginScene(
switch (action.history ?? "push") {
case "push":
return origin.key;
case "replace":
// Skip the origin in the retained chain. The old entry remains mounted
// until commit so an interactive replacement can still be cancelled.
return origin.previousNodeKey;
case "back":
return origin.previousNodeKey;
}
@@ -275,9 +285,7 @@ export function createOriginScene(
function visualEffects(nodeKey: string) {
const result = inheritedEffects(nodeKey);
const outgoing = [...operations.values()].find(
(operation) => operation.originKey === nodeKey,
);
const outgoing = visualOutgoingFor(nodeKey);
if (!outgoing) return result;
const effects = effectSetFor(outgoing);
return [
@@ -287,6 +295,12 @@ export function createOriginScene(
];
}
function visualOutgoingFor(nodeKey: string) {
return [...operations.values()].find(
(operation) => operation.originKey === nodeKey,
);
}
/**
* Collapse independent effect layers into one host style. Geometry remains
* composable; arbitrary CSS properties use normal local-last precedence.
@@ -350,13 +364,15 @@ export function createOriginScene(
function outgoingFor(nodeKey: string) {
return [...operations.values()].find(
(operation) => operation.originKey === nodeKey,
(operation) =>
operation.originKey === nodeKey && operation.phase !== "finished",
);
}
function isNodeInteractive(nodeKey: string) {
const node = nodes.get(nodeKey);
if (!node || node.state === "parked") return false;
if (!node || node.state === "parked" || node.state === "exposed")
return false;
// A target being cancelled is already scheduled to disappear. A source
// settling toward commit has ceded new interactions to the retained scene
@@ -407,9 +423,12 @@ export function createOriginScene(
}
function refreshVisibleState(node: SceneNodeState) {
if (node.state === "parked") return;
if (node.state === "parked" || node.state === "exposed") return;
const incoming = node.incomingOperationId
? operations.get(node.incomingOperationId)
: undefined;
node.state =
node.incomingOperationId || outgoingFor(node.key)
(incoming && incoming.phase !== "finished") || outgoingFor(node.key)
? "transitioning"
: "active";
}
@@ -437,8 +456,9 @@ export function createOriginScene(
/**
* 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.
* A normal push parks its origin and collapses the temporary visual edge.
* A connected push keeps the edge at progress 1 and leaves the origin
* exposed but inert. Replace and back remove the origin.
*/
function commitOperation(id: number) {
const operation = operations.get(id);
@@ -447,15 +467,28 @@ export function createOriginScene(
const target = nodes.get(operation.targetKey);
if (!origin || !target) return false;
if (operation.history === "push" && operation.choreography.persistAtRest) {
operation.progress = 1;
operation.velocity = 0;
operation.intent = "commit";
operation.phase = "finished";
origin.state = "exposed";
target.state = "active";
refreshVisibleState(target);
return true;
}
spliceVisualTarget(origin, target);
operation.phase = "finished";
operations.delete(operation.id);
suspendedPresentations.delete(operation.id);
if (operation.history === "push") {
origin.incomingOperationId = undefined;
origin.state = "parked";
} else {
// A committed back pops only the current retained history entry.
// Back pops the current entry; replace discards it in favor of the new
// target. Both preserve every earlier retained instance.
removeNode(origin.key);
}
@@ -469,11 +502,22 @@ export function createOriginScene(
if (!operation) return;
const origin = nodes.get(operation.originKey);
const target = nodes.get(operation.entryTargetKey);
const suspendedPresentation = suspendedPresentations.get(id);
operation.phase = "finished";
operations.delete(id);
suspendedPresentations.delete(id);
if (operation.history === "back") {
if (target) {
if (suspendedPresentation && origin && target) {
/*
* Put the retained page back into its former parent/root position, then
* reconnect the settled page→drawer edge. Both nodes keep their VNodes.
*/
spliceVisualTarget(origin, target);
operations.set(suspendedPresentation.id, suspendedPresentation);
origin.incomingOperationId = suspendedPresentation.id;
target.state = "exposed";
} else if (target) {
target.incomingOperationId = undefined;
target.state = "parked";
}
@@ -539,14 +583,14 @@ export function createOriginScene(
) {
const operation = operations.get(id);
if (!operation) return false;
if (operation.phase === "finished") return operation.intent === "commit";
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.progress >= 0.0 && operation.velocity >= 0.3));
// operation.velocity >= velocityThreshold));
// The outcome is known synchronously at release. The target can therefore
@@ -568,8 +612,10 @@ export function createOriginScene(
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 (origin.state === "parked" || origin.state === "exposed")
throw new Error(
`Cannot animate from ${origin.state} origin "${originKey}".`,
);
if (outgoingFor(originKey))
throw new Error(
`Origin "${originKey}" already has an outgoing operation. ` +
@@ -577,7 +623,12 @@ export function createOriginScene(
);
const history = action.history ?? "push";
if (action.choreography.persistAtRest && history !== "push")
throw new Error(
"persistAtRest choreographies require retained-history push navigation.",
);
let target: SceneNodeState;
let suspendedPresentation: MutableOriginOperation | undefined;
if (history === "back") {
const previousKey = origin.previousNodeKey;
@@ -606,7 +657,22 @@ export function createOriginScene(
throw new Error(
"Cannot go back through an undecided forward operation.",
);
commitOperation(incoming.id);
if (incoming.phase !== "finished") commitOperation(incoming.id);
const connected = operations.get(incoming.id);
if (
connected?.phase === "finished" &&
connected.choreography.persistAtRest
) {
/*
* Re-root A→page→drawer as A→drawer before adding drawer→page.
* The reverse choreography starts at the same visual endpoints.
*/
operations.delete(connected.id);
spliceVisualTarget(retainedTarget, origin);
retainedTarget.incomingOperationId = undefined;
suspendedPresentation = connected;
}
}
if (outgoingFor(retainedTarget.key))
@@ -645,6 +711,8 @@ export function createOriginScene(
originRect: elementRect(elements.get(originKey)),
});
operations.set(operation.id, operation);
if (suspendedPresentation)
suspendedPresentations.set(operation.id, suspendedPresentation);
// Let Vue mount the target before measuring it. Gesture composables buffer
// pointer progress while this short preparation step is pending.
@@ -682,7 +750,10 @@ export function createOriginScene(
})) as readonly OriginSceneNode[],
),
operations: computed(
() => [...operations.values()] as readonly OriginOperation[],
() =>
[...operations.values()].filter(
(operation) => operation.phase !== "finished",
) as readonly OriginOperation[],
),
roots,
contextFor,

View File

@@ -48,9 +48,11 @@ 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.
* - `replace`: create a target that inherits the origin's previous entry, then
* remove the origin on commit.
* - `back`: reuse the mounted previous entry and pop the origin on commit.
*/
export type OriginHistoryMode = "push" | "back";
export type OriginHistoryMode = "push" | "replace" | "back";
/**
* The target's stacking relationship to its origin while an operation exists.
@@ -62,9 +64,12 @@ export type OriginPlacement = "above" | "under";
*
* - `active`: currently exposed for normal interaction.
* - `transitioning`: participating in at least one live operation edge.
* - `exposed`: retained and visually presented behind/beside the active entry,
* but inert.
* - `parked`: retained in history but visually hidden and inert.
*/
export type OriginSceneNodeState = "active" | "transitioning" | "parked";
export type OriginSceneNodeState =
"active" | "transitioning" | "exposed" | "parked";
/** A rectangle measured in viewport CSS pixels. */
export interface OriginRect {
@@ -172,13 +177,27 @@ export interface OriginChoreography {
* @defaultValue `0.9`
*/
readonly commitVelocity?: number;
/**
* Keep this choreography's progress-`1` effects connected after a committed
* push instead of collapsing the edge and hiding its source.
*
* This is intended for partial presentations such as drawers and inspectors
* where part of the retained source remains visible beside the target. The
* source remains mounted, visible, and inert. A back operation temporarily
* suspends the resting edge; cancelling back restores it.
*
* Only retained-history `push` actions support connected resting effects.
*
* @defaultValue `false`
*/
readonly persistAtRest?: boolean;
}
/** 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.
* Recipe for a newly created push or replacement 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. */
@@ -206,8 +225,8 @@ export interface OriginAction {
*/
export interface OriginNavigationIntent {
/**
* Recipe for a newly created target. Back navigation omits this because the
* scene resolves the retained previous instance.
* Recipe for a newly created push or replacement 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. */
@@ -234,7 +253,11 @@ export interface OriginSceneNode {
readonly previousNodeKey?: string;
/** Current visibility/lifecycle role of this mounted instance. */
readonly state: OriginSceneNodeState;
/** Live operation currently positioning this node as its target. */
/**
* Operation edge positioning this node as its target. This may identify a
* connected resting edge that is intentionally absent from live-operation
* diagnostics.
*/
readonly incomingOperationId?: number;
}
@@ -341,8 +364,8 @@ export interface OriginScene {
* 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.
* @throws If the origin is missing, parked/exposed and inert, or already owns
* an outgoing operation.
*/
begin(
originKey: string,