From 056f341cd3d6a63b9795a46bfbb39ef0aa66c9a8 Mon Sep 17 00:00:00 2001 From: Harvmaster Date: Fri, 31 Jul 2026 07:51:50 +0000 Subject: [PATCH] Increase complexity of test demo --- apps/origins-demo/src/motions.ts | 51 ++ apps/origins-demo/src/style.css | 522 ++++++++++++++++++ apps/origins-demo/src/views/FirstView.vue | 6 +- apps/origins-demo/src/views/HubView.vue | 24 + .../src/views/OrderConfirmationView.vue | 74 +++ .../src/views/PartialDrawerPageView.vue | 99 ++++ .../src/views/PartialDrawerView.vue | 84 +++ .../src/views/PaymentDetailsView.vue | 156 ++++++ docs/routeless-origins.md | 39 +- packages/core-v2/API.md | 85 ++- packages/core-v2/README.md | 67 ++- packages/core-v2/src/gesture.test.ts | 12 +- packages/core-v2/src/motion.ts | 47 +- packages/core-v2/src/scene.test.ts | 227 ++++++++ packages/core-v2/src/scene.ts | 107 +++- packages/core-v2/src/types.ts | 41 +- 16 files changed, 1589 insertions(+), 52 deletions(-) create mode 100644 apps/origins-demo/src/views/OrderConfirmationView.vue create mode 100644 apps/origins-demo/src/views/PartialDrawerPageView.vue create mode 100644 apps/origins-demo/src/views/PartialDrawerView.vue create mode 100644 apps/origins-demo/src/views/PaymentDetailsView.vue diff --git a/apps/origins-demo/src/motions.ts b/apps/origins-demo/src/motions.ts index ae3756b..8733017 100644 --- a/apps/origins-demo/src/motions.ts +++ b/apps/origins-demo/src/motions.ts @@ -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 diff --git a/apps/origins-demo/src/style.css b/apps/origins-demo/src/style.css index f2860f4..ae044d0 100644 --- a/apps/origins-demo/src/style.css +++ b/apps/origins-demo/src/style.css @@ -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) { diff --git a/apps/origins-demo/src/views/FirstView.vue b/apps/origins-demo/src/views/FirstView.vue index 13ee247..7297b48 100644 --- a/apps/origins-demo/src/views/FirstView.vue +++ b/apps/origins-demo/src/views/FirstView.vue @@ -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); diff --git a/apps/origins-demo/src/views/HubView.vue b/apps/origins-demo/src/views/HubView.vue index 9f7bc9e..fe15ec9 100644 --- a/apps/origins-demo/src/views/HubView.vue +++ b/apps/origins-demo/src/views/HubView.vue @@ -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; diff --git a/apps/origins-demo/src/views/OrderConfirmationView.vue b/apps/origins-demo/src/views/OrderConfirmationView.vue new file mode 100644 index 0000000..03fadd7 --- /dev/null +++ b/apps/origins-demo/src/views/OrderConfirmationView.vue @@ -0,0 +1,74 @@ + + + diff --git a/apps/origins-demo/src/views/PartialDrawerPageView.vue b/apps/origins-demo/src/views/PartialDrawerPageView.vue new file mode 100644 index 0000000..fc00103 --- /dev/null +++ b/apps/origins-demo/src/views/PartialDrawerPageView.vue @@ -0,0 +1,99 @@ + + + diff --git a/apps/origins-demo/src/views/PartialDrawerView.vue b/apps/origins-demo/src/views/PartialDrawerView.vue new file mode 100644 index 0000000..934c26c --- /dev/null +++ b/apps/origins-demo/src/views/PartialDrawerView.vue @@ -0,0 +1,84 @@ + + + diff --git a/apps/origins-demo/src/views/PaymentDetailsView.vue b/apps/origins-demo/src/views/PaymentDetailsView.vue new file mode 100644 index 0000000..650c3e9 --- /dev/null +++ b/apps/origins-demo/src/views/PaymentDetailsView.vue @@ -0,0 +1,156 @@ + + + diff --git a/docs/routeless-origins.md b/docs/routeless-origins.md index 4323e11..ff28038 100644 --- a/docs/routeless-origins.md +++ b/docs/routeless-origins.md @@ -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 ``. +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 ``. diff --git a/packages/core-v2/API.md b/packages/core-v2/API.md index ba9f6fa..4533a21 100644 --- a/packages/core-v2/API.md +++ b/packages/core-v2/API.md @@ -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, { @@ -352,10 +379,11 @@ 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 | -| `"back"` | Reuse the retained previous target; pop and unmount the origin | +| Mode | Commit behavior | +| ----------- | -------------------------------------------------------------- | +| `"push"` | Park and retain the origin; activate the new target | +| `"replace"` | Create a new target, inherit prior history, unmount the origin | +| `"back"` | Reuse the retained previous target; pop and unmount the origin | Parked entries are `inert`, `aria-hidden`, invisible, and excluded from pointer input. They remain mounted until back pops them or the scene is destroyed. @@ -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. diff --git a/packages/core-v2/README.md b/packages/core-v2/README.md index e26b721..93a9da4 100644 --- a/packages/core-v2/README.md +++ b/packages/core-v2/README.md @@ -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 diff --git a/packages/core-v2/src/gesture.test.ts b/packages/core-v2/src/gesture.test.ts index 73acf70..ca9ac68 100644 --- a/packages/core-v2/src/gesture.test.ts +++ b/packages/core-v2/src/gesture.test.ts @@ -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", () => { diff --git a/packages/core-v2/src/motion.ts b/packages/core-v2/src/motion.ts index a5e76bd..72a4c8a 100644 --- a/packages/core-v2/src/motion.ts +++ b/packages/core-v2/src/motion.ts @@ -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. * diff --git a/packages/core-v2/src/scene.test.ts b/packages/core-v2/src/scene.test.ts index 128e445..e95fbff 100644 --- a/packages/core-v2/src/scene.test.ts +++ b/packages/core-v2/src/scene.test.ts @@ -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" }); diff --git a/packages/core-v2/src/scene.ts b/packages/core-v2/src/scene.ts index dfddbfd..aaa9395 100644 --- a/packages/core-v2/src/scene.ts +++ b/packages/core-v2/src/scene.ts @@ -155,6 +155,12 @@ export function createOriginScene( ): OriginScene { const nodes = shallowReactive(new Map()); const operations = shallowReactive(new Map()); + /* + * 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(); const roots = shallowRef([]); const elements = new Map(); 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,15 +583,15 @@ 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.velocity >= velocityThreshold)); + (operation.progress >= 0.0 && 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. @@ -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, diff --git a/packages/core-v2/src/types.ts b/packages/core-v2/src/types.ts index a3868c6..26798c0 100644 --- a/packages/core-v2/src/types.ts +++ b/packages/core-v2/src/types.ts @@ -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,