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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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