Gestures - Cancel a captured gesture on lostpointercapture, on a new primary pointer-down, and when its component unmounts, so operations are never left stuck mid-transition. Bindings gain onLostpointercapture. - Warn (console.warn) when navigate() fails or the scene rejects an operation; stay quiet when two recognizers race for one origin. - OriginGesture and OriginGestureSurface props are reactive. Changes apply from the next pointer-down; recognizers are kept per list position so re-renders never interrupt a drag. - Remove the legacy direction/edge/threshold/action gesture form (OriginGestureOptions, legacy OriginGesture props, MaybeOriginAction). Scene - Honour choreography commitVelocity (default 0.3, as before). - Scene errors are OriginSceneError with a stable code. - update() during settling now really catches an operation: the spring stops, and only the latest finish() decides the outcome. - Fix graph corruption found by the new property tests, all present on main: replacements escaping a cancelled push, edges left dangling when their current target differs from the original one, revealed targets keeping operations after their back is cancelled, history pointing at removed entries after replace, visual cycles from back through an undecided replace, exposed nodes reactivated as active, and a cancel cascade that could empty the whole scene. Tests - Add property tests checking structural invariants after every step of random operation sequences and pointer streams (PROPERTY_SEEDS). - Add recognizer tests for stuck gestures, warnings, reactive props, and commitVelocity. Drop the main-comparison suites; keep benchmarks. Also fix the origins demo's two pre-existing type errors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
277 lines
8.2 KiB
Vue
277 lines
8.2 KiB
Vue
<script setup lang="ts">
|
|
import {
|
|
OriginGesture,
|
|
OriginScene,
|
|
back,
|
|
createOriginScene,
|
|
gesture,
|
|
originView,
|
|
useOrigin,
|
|
type OriginView,
|
|
} from "@native-vue-router/core-v2";
|
|
import { useDemoInstance } from "../lab-state";
|
|
import { unfocusPortal } from "../motions";
|
|
import NestedSceneSlide from "./NestedSceneSlide.vue";
|
|
|
|
type NestedSceneKind = "cooperative" | "vertical" | "conflict";
|
|
|
|
interface SlideCopy {
|
|
eyebrow: string;
|
|
title: string;
|
|
description: string;
|
|
accent: string;
|
|
}
|
|
|
|
const slideSets: Record<NestedSceneKind, readonly SlideCopy[]> = {
|
|
cooperative: [
|
|
{
|
|
eyebrow: "Cooperative carousel",
|
|
title: "A scene inside a page",
|
|
description:
|
|
"Swipe left away from the glowing pass-through rail. The target mounts only inside this clipped carousel.",
|
|
accent: "#76dcff",
|
|
},
|
|
{
|
|
eyebrow: "Retained nested history",
|
|
title: "Slide one is still mounted",
|
|
description:
|
|
"Increment local state, move again, then return. The same nested Vue instances are revealed.",
|
|
accent: "#a78bfa",
|
|
},
|
|
{
|
|
eyebrow: "Independent operation graph",
|
|
title: "Three carousel instances",
|
|
description:
|
|
"This history belongs to the carousel scene. The outer page remains one mounted application entry.",
|
|
accent: "#65f2b1",
|
|
},
|
|
],
|
|
vertical: [
|
|
{
|
|
eyebrow: "Vertical nested deck",
|
|
title: "Pull upward inside this card",
|
|
description:
|
|
"The deck uses vertical progress, rotation, and a shared sideways frame arc.",
|
|
accent: "#ffbd70",
|
|
},
|
|
{
|
|
eyebrow: "Orthogonal choreography",
|
|
title: "The page still owns its left rail",
|
|
description:
|
|
"Pull down to reveal the retained card, or begin on its pass-through rail and swipe right to leave the lab.",
|
|
accent: "#ff7f9f",
|
|
},
|
|
{
|
|
eyebrow: "Nested composition",
|
|
title: "Frames remain local",
|
|
description:
|
|
"Transforms compose exactly like the full-screen scene, but measurements come from this smaller viewport.",
|
|
accent: "#ffe079",
|
|
},
|
|
],
|
|
conflict: [
|
|
{
|
|
eyebrow: "Intentional conflict",
|
|
title: "This child claims every pointer-down",
|
|
description:
|
|
"Try the page's rightward back gesture over this card. The child sees it first, even though nested back is unavailable.",
|
|
accent: "#ff5f78",
|
|
},
|
|
{
|
|
eyebrow: "Child-owned back",
|
|
title: "Now rightward swipe works here",
|
|
description:
|
|
"After moving forward, the exact same gesture can resolve nested back, producing the cube reversal.",
|
|
accent: "#ff8d4d",
|
|
},
|
|
],
|
|
};
|
|
|
|
function nestedView(kind: NestedSceneKind, index: number): OriginView {
|
|
const slides = slideSets[kind];
|
|
const copy = slides[index]!;
|
|
return originView(
|
|
NestedSceneSlide,
|
|
{
|
|
kind,
|
|
index,
|
|
total: slides.length,
|
|
...copy,
|
|
next:
|
|
index + 1 < slides.length
|
|
? (): OriginView => nestedView(kind, index + 1)
|
|
: undefined,
|
|
},
|
|
{
|
|
key: `${kind}-slide-${index + 1}`,
|
|
name: `${kind} slide ${index + 1}`,
|
|
},
|
|
);
|
|
}
|
|
|
|
function nestedScene(kind: NestedSceneKind) {
|
|
return createOriginScene({ initial: nestedView(kind, 0) });
|
|
}
|
|
|
|
interface NestedCase {
|
|
kind: NestedSceneKind;
|
|
status: "supported" | "supported-with-policy" | "known-conflict";
|
|
title: string;
|
|
summary: string;
|
|
expected: string;
|
|
issue: string;
|
|
scene: ReturnType<typeof createOriginScene>;
|
|
}
|
|
|
|
const cases: readonly NestedCase[] = [
|
|
{
|
|
kind: "cooperative",
|
|
status: "supported-with-policy",
|
|
title: "Reserved-edge carousel",
|
|
summary:
|
|
"Horizontal nested history with a start predicate that leaves a clamp(22px, 12%, 52px) rail for its parent.",
|
|
expected:
|
|
"Swipe left or right beyond the rail; swipe right from the card's rail to trigger parent back.",
|
|
issue:
|
|
"The parent/child agreement is spatial policy written by the developer, not automatic arbitration.",
|
|
scene: nestedScene("cooperative"),
|
|
},
|
|
{
|
|
kind: "vertical",
|
|
status: "supported",
|
|
title: "Vertical component deck",
|
|
summary:
|
|
"A second nested scene uses upward/downward gestures and an entirely different frame animation.",
|
|
expected:
|
|
"Swipe up for the next card, down for retained back, and swipe right from its rail for parent back.",
|
|
issue:
|
|
"Its touch-action prevents page scrolling while a touch begins over the deck, which is appropriate here but must be intentional.",
|
|
scene: nestedScene("vertical"),
|
|
},
|
|
{
|
|
kind: "conflict",
|
|
status: "known-conflict",
|
|
title: "Greedy child recognizer",
|
|
summary:
|
|
"The cube carousel deliberately uses `.to.left()` and `.to.right()` without a `.from` constraint.",
|
|
expected: "Nested forward/back works and remains clipped to the card.",
|
|
issue:
|
|
"On slide one, parent back fails everywhere over this card: the child stops pointer-down propagation before `.navigate()` returns null.",
|
|
scene: nestedScene("conflict"),
|
|
},
|
|
];
|
|
|
|
const origin = useOrigin();
|
|
const identity = useDemoInstance("Nested scenes");
|
|
|
|
const pageBackGesture = gesture.to
|
|
.right()
|
|
.complete(
|
|
({ distance, bounds, progress, velocity }) =>
|
|
distance >= bounds.width * 0.08 || progress >= 0.22 || velocity >= 0.65,
|
|
)
|
|
.navigate((context) => (context.canGoBack ? back() : null))
|
|
.animate(unfocusPortal);
|
|
|
|
function backWithButton() {
|
|
if (origin.context.value.canGoBack) void origin.perform(back(unfocusPortal));
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<OriginGesture class="lab-page nested-scenes-view" :gesture="pageBackGesture">
|
|
<button
|
|
class="view-back"
|
|
type="button"
|
|
data-origin-gesture="ignore"
|
|
@click="backWithButton"
|
|
>
|
|
← Labs
|
|
</button>
|
|
|
|
<header class="nested-scenes-header">
|
|
<div>
|
|
<div class="eyebrow">Lab 07 · Nested origin scenes</div>
|
|
<h1>Components can own smaller worlds.</h1>
|
|
<p>
|
|
Each card below contains a completely independent
|
|
<code>OriginScene</code>. Its views, history, gestures, measurements,
|
|
and animations stay inside that card while this page remains the outer
|
|
origin.
|
|
</p>
|
|
</div>
|
|
<div class="nested-page-instance">
|
|
<span>Outer page instance</span>
|
|
<strong>{{ identity.instance }}</strong>
|
|
<small>rightward 8% → page back</small>
|
|
</div>
|
|
</header>
|
|
|
|
<main class="nested-case-grid">
|
|
<section
|
|
v-for="item in cases"
|
|
:key="item.kind"
|
|
class="nested-case"
|
|
:class="`nested-case--${item.kind}`"
|
|
>
|
|
<header>
|
|
<span class="nested-case__status" :class="item.status">
|
|
{{ item.status.replaceAll("-", " ") }}
|
|
</span>
|
|
<h2>{{ item.title }}</h2>
|
|
<p>{{ item.summary }}</p>
|
|
</header>
|
|
|
|
<div class="nested-stage-shell">
|
|
<OriginScene class="nested-stage" :scene="item.scene" />
|
|
<div class="nested-stage-edge" aria-hidden="true">
|
|
{{
|
|
item.kind === "conflict"
|
|
? "parent blocked"
|
|
: "parent pass-through"
|
|
}}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="nested-case__diagnostics">
|
|
<span>
|
|
{{ item.scene.nodes.value.length }} nested node{{
|
|
item.scene.nodes.value.length === 1 ? "" : "s"
|
|
}}
|
|
</span>
|
|
<span>
|
|
{{ item.scene.operations.value.length }} live edge{{
|
|
item.scene.operations.value.length === 1 ? "" : "s"
|
|
}}
|
|
</span>
|
|
</div>
|
|
|
|
<dl>
|
|
<div>
|
|
<dt>Expected</dt>
|
|
<dd>{{ item.expected }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>
|
|
{{ item.kind === "conflict" ? "Predicted failure" : "Caveat" }}
|
|
</dt>
|
|
<dd>{{ item.issue }}</dd>
|
|
</div>
|
|
</dl>
|
|
</section>
|
|
</main>
|
|
|
|
<footer class="nested-scenes-footer">
|
|
<strong>Ownership today is decided at pointer-down.</strong>
|
|
<span>
|
|
A future gesture arena could delay that decision and let a parent claim
|
|
the sequence when a child declines navigation.
|
|
</span>
|
|
</footer>
|
|
|
|
<div class="nested-page-gesture">
|
|
Parent gesture: swipe right outside a child, or from a cooperative rail
|
|
</div>
|
|
</OriginGesture>
|
|
</template>
|