Compare commits
14 Commits
49cecad06d
...
routeless-
| Author | SHA1 | Date | |
|---|---|---|---|
| 056f341cd3 | |||
| 55dad11b25 | |||
| 5a514906eb | |||
| 6aed7606ad | |||
| bfe364c57d | |||
| 18baa96848 | |||
|
d28b09dc21
|
|||
|
c27e5906ca
|
|||
|
db864af147
|
|||
|
7d134ff8c9
|
|||
|
92c61abcfe
|
|||
|
9af84760fa
|
|||
|
d2d82ae6ae
|
|||
|
ee25b9a94d
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -13,6 +13,7 @@ dist-ssr
|
|||||||
coverage
|
coverage
|
||||||
playwright-report
|
playwright-report
|
||||||
test-results
|
test-results
|
||||||
|
apps/demo/dev-dist
|
||||||
.vite
|
.vite
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
@@ -26,3 +27,5 @@ test-results
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
docker-compose.yml
|
||||||
70
README.md
70
README.md
@@ -9,7 +9,7 @@ The repository includes a reusable headless core, a platform-adaptive visual pre
|
|||||||
- Interactive edge pop that can be held indefinitely at any progress.
|
- Interactive edge pop that can be held indefinitely at any progress.
|
||||||
- Ordered horizontal route paging with replace-by-default history.
|
- Ordered horizontal route paging with replace-by-default history.
|
||||||
- Component-originated route dragging with a live target route.
|
- Component-originated route dragging with a live target route.
|
||||||
- Interactive push, adjacent-page sibling slide, modal, sheet, fade, and application-defined presentations.
|
- Interactive push, adjacent-page sibling slide, modal, safe-area-contained content/snap-point sheets with scroll-boundary handoff, fade, and application-defined presentations.
|
||||||
- Concurrent `from` and `to` routes using only public Vue Router 5 APIs.
|
- Concurrent `from` and `to` routes using only public Vue Router 5 APIs.
|
||||||
- Guarded commits: previews do not alter the URL, and rejected navigation springs back.
|
- Guarded commits: previews do not alter the URL, and rejected navigation springs back.
|
||||||
- Cold-start predictive back through declared parent routes.
|
- Cold-start predictive back through declared parent routes.
|
||||||
@@ -35,34 +35,48 @@ npm run cap:sync # build and synchronize iOS and Android projects
|
|||||||
|
|
||||||
Native projects live under `apps/capacitor/ios` and `apps/capacitor/android`. Open or run them from `apps/capacitor` with `npx cap open ios`, `npx cap open android`, or `npx cap run <platform>`.
|
Native projects live under `apps/capacitor/ios` and `apps/capacitor/android`. Open or run them from `apps/capacitor` with `npx cap open ios`, `npx cap open android`, or `npx cap run <platform>`.
|
||||||
|
|
||||||
|
### Test the installed iOS PWA
|
||||||
|
|
||||||
|
Run the normal `npm run dev` command, expose its printed network address through an HTTPS URL, and open that URL on the iPhone. The development server includes the PWA service worker and already listens on the local network. Safari still requires a secure context for the service worker; a plain LAN `http://` address is not sufficient. Choose **Share → Add to Home Screen**, then launch **NVR Messenger** from its Home Screen icon.
|
||||||
|
|
||||||
|
The Navigation Lab reports `Standalone`, `ready`, and `App reserved` when the correct environment is active, and shows the exact build ID plus update-check count. Production builds check for updates whenever the app starts, returns to the foreground, regains connectivity, or has been open for a minute; activation reload waits for any live gesture to finish. Open a conversation and drag from the extreme left edge. The “Leading-edge touches claimed” counter should increment while the router renders its live predictive-back view.
|
||||||
|
|
||||||
|
For more aggressive lifecycle testing, open **You → Runtime stress lab**. It is a deeper route that keeps the primary tab bar, opts into push-style sibling history, exposes its mount lifetime, and renders a one-second async child through `<Suspense>`. Backing out evicts this pushed screen after its exit; browser Forward reconstructs it and shows the fallback again. To exercise a guard against an already-mounted destination, visit **Stories**, switch to **You**, enable **Block cached Stories re-entry**, and try returning to Stories. The guard rejects and evicts the cached view.
|
||||||
|
|
||||||
|
An installed web app cannot access `WKWebView.allowsBackForwardNavigationGestures`. The demo therefore reserves leading-edge touch sequences at the web-content boundary as an iOS standalone-only safeguard. Capacitor remains the deterministic option when native-level gesture suppression is required.
|
||||||
|
|
||||||
## Minimal integration
|
## Minimal integration
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { createApp } from 'vue'
|
import { createApp } from "vue";
|
||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from "vue-router";
|
||||||
import { createNativeRouter } from '@native-vue-router/core'
|
import { createNativeRouter } from "@native-vue-router/core";
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
routes: [
|
routes: [
|
||||||
{ path: '/', component: Home },
|
{ path: "/", component: Home },
|
||||||
{
|
{
|
||||||
path: '/chat/:id',
|
path: "/chat/:id",
|
||||||
component: Chat,
|
component: Chat,
|
||||||
meta: {
|
meta: {
|
||||||
native: { presentation: 'push', parent: '/', gesture: 'edge' },
|
native: { presentation: "push", parent: "/", gesture: "edge" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
});
|
||||||
|
|
||||||
const nativeRouter = createNativeRouter({ router })
|
const nativeRouter = createNativeRouter({ router });
|
||||||
createApp(App).use(router).use(nativeRouter).mount('#app')
|
createApp(App).use(router).use(nativeRouter).mount("#app");
|
||||||
```
|
```
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NativeGestureLink, NativeNavigator, NativeRouterView } from '@native-vue-router/core'
|
import {
|
||||||
|
NativeGestureLink,
|
||||||
|
NativeNavigator,
|
||||||
|
NativeRouterView,
|
||||||
|
} from "@native-vue-router/core";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -79,6 +93,31 @@ Import `@native-vue-router/core/style.css` for the built-in presentation layers.
|
|||||||
|
|
||||||
Use `nativeRouter.sibling(to)` for tab or peer-route navigation. Direction is derived from `siblingOrder`, repeated navigation to the active route is a no-op, and `siblingHistory: 'replace'` keeps cached tab views out of the back stack.
|
Use `nativeRouter.sibling(to)` for tab or peer-route navigation. Direction is derived from `siblingOrder`, repeated navigation to the active route is a no-op, and `siblingHistory: 'replace'` keeps cached tab views out of the back stack.
|
||||||
|
|
||||||
|
Sibling views are lazy rather than pre-mounted: only the initial route exists on startup, and a sibling joins the bounded cache on its first visit or interactive preview. Route metadata accepts `cache: false` to opt out or `cache: 'pin'` for views that must survive ordinary trimming. `useNativeViewLifecycle()`, the `onNativeView*` hooks, and `useNativeViewActiveEffect()` let cached screens pause polling, media, or subscriptions while retaining their local UI state.
|
||||||
|
|
||||||
|
Call `nativeRouter.unload('/some-route')` to manually unmount inactive instances of one location while retaining their lightweight history descriptors. The active route and views participating in a transition are protected.
|
||||||
|
|
||||||
|
### Capture frame pacing on a real device
|
||||||
|
|
||||||
|
The Navigation Lab contains an opt-in profiler. Tap **Start profiling**, leave the lab, reproduce the choppy navigation once or twice, return to the lab, tap **Stop**, then **Share JSON**. Installed iOS PWAs use the system share sheet; other browsers download the file. Attach that JSON to a bug report.
|
||||||
|
|
||||||
|
The core API is also available directly:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createNativeNavigationProfiler } from "@native-vue-router/core";
|
||||||
|
|
||||||
|
const profiler = createNativeNavigationProfiler(nativeRouter, {
|
||||||
|
metadata: { build: import.meta.env.VITE_BUILD_ID },
|
||||||
|
});
|
||||||
|
|
||||||
|
profiler.start();
|
||||||
|
// Reproduce the navigation issue.
|
||||||
|
const report = profiler.stop();
|
||||||
|
const json = profiler.toJSON(report);
|
||||||
|
```
|
||||||
|
|
||||||
|
No rAF loop or browser performance observer runs before `start()`, and `stop()` removes them. Reports contain frame intervals, refresh-rate estimates, per-navigation timing, cold-mount preparation, route loading, cache eviction, visibility changes, and browser-supported Long Task/layout-shift/resource timing. Route params, query values, and application state are omitted.
|
||||||
|
|
||||||
## Packages
|
## Packages
|
||||||
|
|
||||||
- `@native-vue-router/core` — transactions, route ledger, concurrent views, gestures, caching, and public components/composables.
|
- `@native-vue-router/core` — transactions, route ledger, concurrent views, gestures, caching, and public components/composables.
|
||||||
@@ -86,7 +125,14 @@ Use `nativeRouter.sibling(to)` for tab or peer-route navigation. Direction is de
|
|||||||
- `@native-vue-router/capacitor` — hardware back, deep links, pause cancellation, root exit, and haptics.
|
- `@native-vue-router/capacitor` — hardware back, deep links, pause cancellation, root exit, and haptics.
|
||||||
- `@native-vue-router/electron` — Chromium history-gesture suppression and renderer back/forward bridging.
|
- `@native-vue-router/electron` — Chromium history-gesture suppression and renderer back/forward bridging.
|
||||||
|
|
||||||
See [architecture](docs/architecture.md) and [platform integration](docs/platforms.md) for the transaction lifecycle and host-specific behavior.
|
Design and engineering documentation:
|
||||||
|
|
||||||
|
- [Complete installation and usage guide](usage.md)
|
||||||
|
- [How it works and why the pattern is uncommon](docs/how-it-works.md)
|
||||||
|
- [Engineering challenges, Vue Router limitations, and trade-offs](docs/challenges-and-tradeoffs.md)
|
||||||
|
- [Core principles, scalability, and flexibility](docs/principles-and-scalability.md)
|
||||||
|
- [Architecture reference](docs/architecture.md)
|
||||||
|
- [Platform integration reference](docs/platforms.md)
|
||||||
|
|
||||||
## Support contract
|
## Support contract
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import type { CapacitorConfig } from '@capacitor/cli'
|
import type { CapacitorConfig } from "@capacitor/cli";
|
||||||
|
|
||||||
const config: CapacitorConfig = {
|
const config: CapacitorConfig = {
|
||||||
appId: 'dev.nativevuerouter.messenger',
|
appId: "dev.nativevuerouter.messenger",
|
||||||
appName: 'Native Vue Messenger',
|
appName: "Native Vue Messenger",
|
||||||
webDir: '../demo/dist',
|
webDir: "../demo/dist",
|
||||||
backgroundColor: '#0b0d12',
|
backgroundColor: "#0b0d12",
|
||||||
plugins: {
|
plugins: {
|
||||||
App: { disableBackButtonHandler: true },
|
App: { disableBackButtonHandler: true },
|
||||||
SplashScreen: {
|
SplashScreen: {
|
||||||
launchAutoHide: true,
|
launchAutoHide: true,
|
||||||
backgroundColor: '#0b0d12',
|
backgroundColor: "#0b0d12",
|
||||||
androidScaleType: 'CENTER_CROP',
|
androidScaleType: "CENTER_CROP",
|
||||||
},
|
},
|
||||||
StatusBar: { style: 'DARK', backgroundColor: '#0b0d12' },
|
StatusBar: { style: "DARK", backgroundColor: "#0b0d12" },
|
||||||
},
|
},
|
||||||
android: { backgroundColor: '#0b0d12' },
|
android: { backgroundColor: "#0b0d12" },
|
||||||
ios: { backgroundColor: '#0b0d12', contentInset: 'never' },
|
ios: { backgroundColor: "#0b0d12", contentInset: "never" },
|
||||||
}
|
};
|
||||||
|
|
||||||
export default config
|
export default config;
|
||||||
|
|||||||
@@ -1,198 +1,812 @@
|
|||||||
import { expect, test, type Page } from '@playwright/test'
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
async function captureTransitions(page: Page) {
|
async function captureTransitions(page: Page) {
|
||||||
|
await page.locator(".nvr-router-view").waitFor({ state: "attached" });
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const state = window as typeof window & {
|
const state = window as typeof window & {
|
||||||
__nvrEvents?: Array<{ direction: string | null; presentation: string | null }>
|
__nvrEvents?: Array<{
|
||||||
__nvrObserver?: MutationObserver
|
direction: string | null;
|
||||||
}
|
presentation: string | null;
|
||||||
state.__nvrObserver?.disconnect()
|
}>;
|
||||||
state.__nvrEvents = []
|
__nvrObserver?: MutationObserver;
|
||||||
const view = document.querySelector('.nvr-router-view')
|
};
|
||||||
if (!view) throw new Error('Native router view did not render')
|
state.__nvrObserver?.disconnect();
|
||||||
|
state.__nvrEvents = [];
|
||||||
|
const view = document.querySelector(".nvr-router-view");
|
||||||
|
if (!view) throw new Error("Native router view did not render");
|
||||||
state.__nvrObserver = new MutationObserver(() => {
|
state.__nvrObserver = new MutationObserver(() => {
|
||||||
if (view.classList.contains('nvr-router-view--interactive')) {
|
if (view.classList.contains("nvr-router-view--interactive")) {
|
||||||
state.__nvrEvents?.push({
|
state.__nvrEvents?.push({
|
||||||
direction: view.getAttribute('data-native-direction'),
|
direction: view.getAttribute("data-native-direction"),
|
||||||
presentation: view.getAttribute('data-native-presentation'),
|
presentation: view.getAttribute("data-native-presentation"),
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
state.__nvrObserver.observe(view, { attributes: true })
|
state.__nvrObserver.observe(view, { attributes: true });
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function recordedTransitions(page: Page) {
|
async function recordedTransitions(page: Page) {
|
||||||
return await page.evaluate(() => (window as typeof window & {
|
return await page.evaluate(
|
||||||
__nvrEvents?: Array<{ direction: string | null; presentation: string | null }>
|
() =>
|
||||||
}).__nvrEvents ?? [])
|
(
|
||||||
|
window as typeof window & {
|
||||||
|
__nvrEvents?: Array<{
|
||||||
|
direction: string | null;
|
||||||
|
presentation: string | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
).__nvrEvents ?? [],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForTransition(page: Page) {
|
async function waitForTransition(page: Page) {
|
||||||
await expect(page.locator('.nvr-router-view')).not.toHaveClass(/nvr-router-view--interactive/)
|
await expect(page.locator(".nvr-router-view")).not.toHaveClass(
|
||||||
|
/nvr-router-view--interactive/,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
test('navigates a conversation and returns through the native runtime', async ({ page }) => {
|
async function flickToNextTab(page: Page, leaveSlowSpring = false) {
|
||||||
await page.goto('/inbox')
|
// Start on the route header, outside conversation-owned drag targets.
|
||||||
await expect(page.getByRole('heading', { name: 'Messages' })).toBeVisible()
|
const surface = await page.locator(".nvr-router-view").boundingBox();
|
||||||
await page.getByText('Maya Chen').last().click()
|
const header = await page
|
||||||
await expect(page).toHaveURL(/\/chat\/maya$/)
|
.locator(
|
||||||
await expect(page.getByRole('heading', { name: 'Maya Chen' })).toBeVisible()
|
'[data-native-role="active"] .app-header, [data-native-role="to"] .app-header',
|
||||||
await page.getByRole('button', { name: 'Back' }).click()
|
)
|
||||||
await expect(page).toHaveURL(/\/inbox$/)
|
.last()
|
||||||
await waitForTransition(page)
|
.boundingBox();
|
||||||
await expect(page.getByRole('heading', { name: 'Messages' })).toBeVisible()
|
if (!surface || !header)
|
||||||
})
|
throw new Error("Active route header did not render");
|
||||||
|
const y = header.y + header.height * 0.5;
|
||||||
|
await page.mouse.move(surface.x + surface.width * 0.72, y);
|
||||||
|
await page.mouse.down();
|
||||||
|
if (leaveSlowSpring) {
|
||||||
|
await page.mouse.move(surface.x + surface.width * 0.3, y, { steps: 10 });
|
||||||
|
await page.waitForTimeout(90);
|
||||||
|
}
|
||||||
|
await page.mouse.move(surface.x + surface.width * 0.27, y);
|
||||||
|
await page.mouse.up();
|
||||||
|
}
|
||||||
|
|
||||||
test('switches sibling routes without growing the primary history flow', async ({ page }) => {
|
test("navigates a conversation and returns through the native runtime", async ({
|
||||||
await page.goto('/inbox')
|
page,
|
||||||
await page.getByRole('link', { name: /Stories/ }).click()
|
}) => {
|
||||||
await expect(page).toHaveURL(/\/stories$/)
|
await page.goto("/inbox");
|
||||||
await waitForTransition(page)
|
await expect(page.getByRole("heading", { name: "Messages" })).toBeVisible();
|
||||||
await expect(page.getByRole('heading', { name: 'Stories' })).toBeVisible()
|
await page.getByText("Maya Chen").last().click();
|
||||||
await page.getByRole('link', { name: /You/ }).click()
|
await expect(page).toHaveURL(/\/chat\/maya$/);
|
||||||
await expect(page).toHaveURL(/\/profile$/)
|
await expect(page.getByRole("heading", { name: "Maya Chen" })).toBeVisible();
|
||||||
await waitForTransition(page)
|
await page.getByRole("button", { name: "Back" }).click();
|
||||||
await expect(page.getByRole('heading', { name: 'You' })).toBeVisible()
|
await expect(page).toHaveURL(/\/inbox$/);
|
||||||
})
|
await waitForTransition(page);
|
||||||
|
await expect(page.getByRole("heading", { name: "Messages" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test('uses route order for tab direction and does not animate the active tab', async ({ page }) => {
|
test("switches sibling routes without growing the primary history flow", async ({
|
||||||
await page.goto('/inbox')
|
page,
|
||||||
await captureTransitions(page)
|
}) => {
|
||||||
await page.getByRole('link', { name: /Stories/ }).click()
|
await page.goto("/inbox");
|
||||||
await expect(page).toHaveURL(/\/stories$/)
|
await page.getByRole("link", { name: /Stories/ }).click();
|
||||||
await waitForTransition(page)
|
await expect(page).toHaveURL(/\/stories$/);
|
||||||
expect(await recordedTransitions(page)).toContainEqual({ direction: 'forward', presentation: 'slide' })
|
await waitForTransition(page);
|
||||||
|
await expect(page.getByRole("heading", { name: "Stories" })).toBeVisible();
|
||||||
|
await page.getByRole("link", { name: /You/ }).click();
|
||||||
|
await expect(page).toHaveURL(/\/profile$/);
|
||||||
|
await waitForTransition(page);
|
||||||
|
await expect(page.getByRole("heading", { name: "You" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
await captureTransitions(page)
|
test("uses route order for tab direction and does not animate the active tab", async ({
|
||||||
await page.getByRole('link', { name: /Inbox/ }).click()
|
page,
|
||||||
await expect(page).toHaveURL(/\/inbox$/)
|
}) => {
|
||||||
await waitForTransition(page)
|
await page.goto("/inbox");
|
||||||
expect(await recordedTransitions(page)).toContainEqual({ direction: 'back', presentation: 'slide' })
|
await captureTransitions(page);
|
||||||
|
await page.getByRole("link", { name: /Stories/ }).click();
|
||||||
|
await expect(page).toHaveURL(/\/stories$/);
|
||||||
|
await waitForTransition(page);
|
||||||
|
expect(await recordedTransitions(page)).toContainEqual({
|
||||||
|
direction: "forward",
|
||||||
|
presentation: "slide",
|
||||||
|
});
|
||||||
|
|
||||||
await captureTransitions(page)
|
await captureTransitions(page);
|
||||||
await page.getByRole('link', { name: /Inbox/ }).click()
|
await page.getByRole("link", { name: /Inbox/ }).click();
|
||||||
await page.waitForTimeout(100)
|
await expect(page).toHaveURL(/\/inbox$/);
|
||||||
expect(await recordedTransitions(page)).toEqual([])
|
await waitForTransition(page);
|
||||||
await expect(page).toHaveURL(/\/inbox$/)
|
expect(await recordedTransitions(page)).toContainEqual({
|
||||||
})
|
direction: "back",
|
||||||
|
presentation: "slide",
|
||||||
|
});
|
||||||
|
|
||||||
test('moves sibling screens edge-to-edge at one-to-one drag progress', async ({ page }) => {
|
await captureTransitions(page);
|
||||||
await page.goto('/stories')
|
await page.getByRole("link", { name: /Inbox/ }).click();
|
||||||
const routerView = page.locator('.nvr-router-view')
|
await page.waitForTimeout(100);
|
||||||
const frame = await routerView.boundingBox()
|
expect(await recordedTransitions(page)).toEqual([]);
|
||||||
if (!frame) throw new Error('Native router view did not render')
|
await expect(page).toHaveURL(/\/inbox$/);
|
||||||
|
});
|
||||||
|
|
||||||
await page.mouse.move(frame.x + frame.width * 0.55, frame.y + frame.height * 0.45)
|
test("interrupts an active tab animation when another tab is tapped", async ({
|
||||||
await page.mouse.down()
|
page,
|
||||||
await page.mouse.move(frame.x + frame.width * 0.8, frame.y + frame.height * 0.45, { steps: 18 })
|
}) => {
|
||||||
await expect(routerView).toHaveAttribute('data-native-presentation', 'slide')
|
await page.goto("/inbox");
|
||||||
await expect(routerView).toHaveAttribute('data-native-direction', 'back')
|
const routerView = page.locator(".nvr-router-view");
|
||||||
|
await page.getByRole("link", { name: /Stories/ }).click();
|
||||||
|
await expect(routerView).toHaveClass(/nvr-router-view--interactive/);
|
||||||
|
const firstTransaction = await routerView.getAttribute(
|
||||||
|
"data-native-transaction",
|
||||||
|
);
|
||||||
|
expect(firstTransaction).not.toBeNull();
|
||||||
|
|
||||||
const from = await page.locator('[data-native-role="from"]').boundingBox()
|
await page.getByRole("link", { name: /You/ }).click();
|
||||||
const to = await page.locator('[data-native-role="to"]').boundingBox()
|
await expect(routerView).not.toHaveAttribute(
|
||||||
if (!from || !to) throw new Error('Both sibling pages must be live during a drag')
|
"data-native-transaction",
|
||||||
expect(Math.abs(from.x - (to.x + to.width))).toBeLessThan(3)
|
firstTransaction!,
|
||||||
await page.mouse.up()
|
{ timeout: 250 },
|
||||||
})
|
);
|
||||||
|
await expect(page.locator('[data-native-role="from"]')).toHaveAttribute(
|
||||||
|
"data-native-route",
|
||||||
|
"/stories",
|
||||||
|
);
|
||||||
|
await expect(page.locator('[data-native-role="to"]')).toHaveAttribute(
|
||||||
|
"data-native-route",
|
||||||
|
"/profile",
|
||||||
|
);
|
||||||
|
await expect(page).toHaveURL(/\/profile$/);
|
||||||
|
});
|
||||||
|
|
||||||
test('opens and dismisses the compose sheet', async ({ page }) => {
|
test("interrupts a settling push animation with an edge-back gesture", async ({
|
||||||
await page.goto('/inbox')
|
page,
|
||||||
await page.getByRole('button', { name: 'Compose' }).click()
|
}) => {
|
||||||
await expect(page).toHaveURL(/\/compose$/)
|
await page.goto("/inbox");
|
||||||
await expect(page.getByRole('heading', { name: 'New message' })).toBeVisible()
|
await page.getByText("Maya Chen").last().click();
|
||||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
await expect(page).toHaveURL(/\/chat\/maya$/);
|
||||||
await expect(page.getByRole('heading', { name: 'Messages' })).toBeVisible()
|
const routerView = page.locator(".nvr-router-view");
|
||||||
})
|
await expect(routerView).toHaveClass(/nvr-router-view--interactive/);
|
||||||
|
const pushTransaction = await routerView.getAttribute(
|
||||||
|
"data-native-transaction",
|
||||||
|
);
|
||||||
|
|
||||||
test('keeps the target live during a held component drag', async ({ page }) => {
|
const frame = await page.locator(".app-frame").boundingBox();
|
||||||
await page.goto('/inbox')
|
if (!frame) throw new Error("App frame did not render");
|
||||||
const row = page.locator('.conversation-row').first()
|
await page.mouse.move(frame.x + 2, frame.y + frame.height * 0.5);
|
||||||
const box = await row.boundingBox()
|
await page.mouse.down();
|
||||||
if (!box) throw new Error('Conversation row did not render')
|
await page.mouse.move(
|
||||||
await page.mouse.move(box.x + box.width * 0.8, box.y + box.height / 2)
|
frame.x + frame.width * 0.34,
|
||||||
await page.mouse.down()
|
frame.y + frame.height * 0.5,
|
||||||
await page.mouse.move(box.x + box.width * 0.35, box.y + box.height / 2, { steps: 12 })
|
{ steps: 16 },
|
||||||
await expect(page.locator('[data-native-role="to"]')).toBeVisible()
|
);
|
||||||
await page.mouse.up()
|
|
||||||
await expect(page.getByRole('heading', { name: 'Maya Chen' })).toBeVisible()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('holds an edge-back preview without committing the URL', async ({ page }) => {
|
await expect(routerView).not.toHaveAttribute(
|
||||||
await page.goto('/inbox')
|
"data-native-transaction",
|
||||||
await page.getByText('Maya Chen').last().click()
|
pushTransaction!,
|
||||||
await expect(page).toHaveURL(/\/chat\/maya$/)
|
{ timeout: 250 },
|
||||||
const frame = await page.locator('.app-frame').boundingBox()
|
);
|
||||||
if (!frame) throw new Error('App frame did not render')
|
await expect(page.locator('[data-native-role="from"]')).toHaveAttribute(
|
||||||
await page.mouse.move(frame.x + 2, frame.y + frame.height * 0.5)
|
"data-native-route",
|
||||||
await page.mouse.down()
|
"/chat/maya",
|
||||||
await page.mouse.move(frame.x + frame.width * 0.55, frame.y + frame.height * 0.5, { steps: 14 })
|
);
|
||||||
await expect(page.locator('[data-native-role="to"]')).toBeVisible()
|
await expect(page.locator('[data-native-role="to"]')).toHaveAttribute(
|
||||||
await expect(page).toHaveURL(/\/chat\/maya$/)
|
"data-native-route",
|
||||||
await page.mouse.up()
|
"/inbox",
|
||||||
await expect(page.getByRole('heading', { name: 'Messages' })).toBeVisible()
|
);
|
||||||
})
|
await page.mouse.up();
|
||||||
|
});
|
||||||
|
|
||||||
test('never previews a stale conversation after a cancelled back gesture', async ({ page }) => {
|
test("moves sibling screens edge-to-edge at one-to-one drag progress", async ({
|
||||||
await page.goto('/inbox')
|
page,
|
||||||
await page.getByText('Maya Chen').last().click()
|
}) => {
|
||||||
await page.getByRole('button', { name: 'Back' }).click()
|
await page.goto("/stories");
|
||||||
await expect(page).toHaveURL(/\/inbox$/)
|
const routerView = page.locator(".nvr-router-view");
|
||||||
await waitForTransition(page)
|
const frame = await routerView.boundingBox();
|
||||||
await page.getByText('Noah Williams').last().click()
|
if (!frame) throw new Error("Native router view did not render");
|
||||||
await expect(page).toHaveURL(/\/chat\/noah$/)
|
|
||||||
await waitForTransition(page)
|
|
||||||
|
|
||||||
const frame = await page.locator('.app-frame').boundingBox()
|
await page.mouse.move(
|
||||||
if (!frame) throw new Error('App frame did not render')
|
frame.x + frame.width * 0.55,
|
||||||
await page.mouse.move(frame.x + 2, frame.y + frame.height * 0.5)
|
frame.y + frame.height * 0.45,
|
||||||
await page.mouse.down()
|
);
|
||||||
await page.mouse.move(frame.x + frame.width * 0.055, frame.y + frame.height * 0.5, { steps: 16 })
|
await page.mouse.down();
|
||||||
await page.mouse.up()
|
await page.mouse.move(
|
||||||
await waitForTransition(page)
|
frame.x + frame.width * 0.8,
|
||||||
await expect(page).toHaveURL(/\/chat\/noah$/)
|
frame.y + frame.height * 0.45,
|
||||||
|
{ steps: 18 },
|
||||||
|
);
|
||||||
|
await expect(routerView).toHaveAttribute("data-native-presentation", "slide");
|
||||||
|
await expect(routerView).toHaveAttribute("data-native-direction", "back");
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Back' }).click()
|
const from = await page.locator('[data-native-role="from"]').boundingBox();
|
||||||
const backTarget = page.locator('[data-native-role="to"]')
|
const to = await page.locator('[data-native-role="to"]').boundingBox();
|
||||||
await expect(backTarget.getByRole('heading', { name: 'Messages' })).toBeVisible()
|
if (!from || !to)
|
||||||
await expect(backTarget.getByRole('heading', { name: 'Maya Chen' })).toHaveCount(0)
|
throw new Error("Both sibling pages must be live during a drag");
|
||||||
await waitForTransition(page)
|
expect(Math.abs(from.x - (to.x + to.width))).toBeLessThan(3);
|
||||||
await expect(page).toHaveURL(/\/inbox$/)
|
await page.mouse.up();
|
||||||
})
|
});
|
||||||
|
|
||||||
test('always opens compose as a vertical sheet after prior navigation', async ({ page }) => {
|
test("accepts a second fast tab flick while the first spring is still settling", async ({
|
||||||
await page.goto('/inbox')
|
page,
|
||||||
await page.getByText('Maya Chen').last().click()
|
}) => {
|
||||||
await page.getByRole('button', { name: 'Back' }).click()
|
await page.goto("/inbox");
|
||||||
await expect(page).toHaveURL(/\/inbox$/)
|
const routerView = page.locator(".nvr-router-view");
|
||||||
await waitForTransition(page)
|
|
||||||
await page.getByRole('link', { name: /Stories/ }).click()
|
|
||||||
await expect(page).toHaveURL(/\/stories$/)
|
|
||||||
await waitForTransition(page)
|
|
||||||
await page.getByRole('link', { name: /Inbox/ }).click()
|
|
||||||
await expect(page).toHaveURL(/\/inbox$/)
|
|
||||||
await waitForTransition(page)
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Compose' }).click()
|
// Commit by distance with a deliberately slow final sample, leaving enough
|
||||||
const routerView = page.locator('.nvr-router-view')
|
// baseline spring for the second fast gesture to interrupt deterministically.
|
||||||
await expect(routerView).toHaveAttribute('data-native-presentation', 'sheet')
|
await flickToNextTab(page, true);
|
||||||
await expect(routerView).toHaveAttribute('data-native-direction', 'up')
|
await expect(page).toHaveURL(/\/stories$/);
|
||||||
const frame = await routerView.boundingBox()
|
await expect(routerView).toHaveClass(/nvr-router-view--interactive/);
|
||||||
const sheet = await page.locator('[data-native-role="to"]').boundingBox()
|
|
||||||
if (!frame || !sheet) throw new Error('Sheet transition did not render')
|
|
||||||
expect(Math.abs(frame.x - sheet.x)).toBeLessThan(3)
|
|
||||||
await waitForTransition(page)
|
|
||||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('drags a sheet down to dismiss it', async ({ page }) => {
|
await flickToNextTab(page);
|
||||||
await page.goto('/inbox')
|
await expect(page).toHaveURL(/\/profile$/);
|
||||||
await page.getByRole('button', { name: 'Compose' }).click()
|
await waitForTransition(page);
|
||||||
const sheet = await page.locator('.sheet-screen').boundingBox()
|
|
||||||
if (!sheet) throw new Error('Sheet did not render')
|
// A stale pointer-up cleanup used to leave an orphaned transaction here,
|
||||||
await page.mouse.move(sheet.x + sheet.width / 2, sheet.y + 12)
|
// permanently blocking both subsequent swipes and imperative tab links.
|
||||||
await page.mouse.down()
|
await page.getByRole("link", { name: /Inbox/ }).click();
|
||||||
await page.mouse.move(sheet.x + sheet.width / 2, sheet.y + sheet.height * 0.55, { steps: 14 })
|
await expect(page).toHaveURL(/\/inbox$/);
|
||||||
await page.mouse.up()
|
await waitForTransition(page);
|
||||||
await expect(page.getByRole('heading', { name: 'Messages' })).toBeVisible()
|
await expect(routerView).not.toHaveAttribute("data-native-transaction");
|
||||||
})
|
});
|
||||||
|
|
||||||
|
test("renders a suspended pushed sibling and evicts it after backing out", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/profile");
|
||||||
|
await page.getByRole("link", { name: /Runtime stress lab/ }).click();
|
||||||
|
|
||||||
|
await expect(page.getByTestId("async-data-loading")).toBeVisible();
|
||||||
|
await expect(page).toHaveURL(/\/profile\/runtime-lab$/);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("navigation", { name: "Primary navigation" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByRole("link", { name: /You/ })).toHaveAttribute(
|
||||||
|
"aria-current",
|
||||||
|
"page",
|
||||||
|
);
|
||||||
|
await expect(page.getByTestId("async-data-ready")).toBeVisible({
|
||||||
|
timeout: 4_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const lab = page.getByTestId("runtime-lab-view");
|
||||||
|
const firstMountId = await lab.getAttribute("data-mount-id");
|
||||||
|
await page.getByRole("button", { name: "Back" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/profile$/);
|
||||||
|
await waitForTransition(page);
|
||||||
|
|
||||||
|
// Popping a pushed route removes it after the exit animation. Keeping the
|
||||||
|
// descriptor allows browser-forward navigation without retaining its DOM.
|
||||||
|
await expect(page.getByTestId("runtime-lab-view")).toHaveCount(0);
|
||||||
|
|
||||||
|
// browser forward exists only because this sibling opted into push history.
|
||||||
|
await page.evaluate(() => history.forward());
|
||||||
|
await expect(page).toHaveURL(/\/profile\/runtime-lab$/);
|
||||||
|
await expect(page.getByTestId("async-data-loading")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("async-data-ready")).toBeVisible({
|
||||||
|
timeout: 4_000,
|
||||||
|
});
|
||||||
|
await expect(page.getByTestId("runtime-lab-view")).not.toHaveAttribute(
|
||||||
|
"data-mount-id",
|
||||||
|
firstMountId!,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clicking the root tab from a pushed sibling collapses its back history", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/profile");
|
||||||
|
await page.getByRole("link", { name: /Runtime stress lab/ }).click();
|
||||||
|
await expect(page).toHaveURL(/\/profile\/runtime-lab$/);
|
||||||
|
|
||||||
|
await page.getByRole("link", { name: /You/ }).click();
|
||||||
|
await expect(page).toHaveURL(/\/profile$/);
|
||||||
|
await waitForTransition(page);
|
||||||
|
|
||||||
|
await expect(page.locator(".nvr-navigator")).toHaveAttribute(
|
||||||
|
"data-native-can-go-back",
|
||||||
|
"false",
|
||||||
|
);
|
||||||
|
await expect(page.getByTestId("runtime-lab-view")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lazily caches a visited sibling and pauses its active work while hidden", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/stories");
|
||||||
|
const stories = page.getByTestId("stories-view");
|
||||||
|
const mountId = await stories.getAttribute("data-mount-id");
|
||||||
|
await expect
|
||||||
|
.poll(async () => Number(await stories.getAttribute("data-active-ticks")))
|
||||||
|
.toBeGreaterThan(1);
|
||||||
|
|
||||||
|
await page.getByRole("link", { name: /You/ }).click();
|
||||||
|
await expect(page).toHaveURL(/\/profile$/);
|
||||||
|
await waitForTransition(page);
|
||||||
|
await expect(stories).toHaveCount(1);
|
||||||
|
|
||||||
|
const hiddenTicks = Number(await stories.getAttribute("data-active-ticks"));
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
await expect(stories).toHaveAttribute(
|
||||||
|
"data-active-ticks",
|
||||||
|
String(hiddenTicks),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.getByRole("link", { name: /Stories/ }).click();
|
||||||
|
await expect(page).toHaveURL(/\/stories$/);
|
||||||
|
await waitForTransition(page);
|
||||||
|
await expect(stories).toHaveAttribute("data-mount-id", mountId!);
|
||||||
|
await expect
|
||||||
|
.poll(async () => Number(await stories.getAttribute("data-active-ticks")))
|
||||||
|
.toBeGreaterThan(hiddenTicks);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("evicts a cached sibling when its dynamic entry guard rejects it", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/stories");
|
||||||
|
const stories = page.getByTestId("stories-view");
|
||||||
|
const firstMountId = await stories.getAttribute("data-mount-id");
|
||||||
|
|
||||||
|
await page.getByRole("link", { name: /You/ }).click();
|
||||||
|
await expect(page).toHaveURL(/\/profile$/);
|
||||||
|
await waitForTransition(page);
|
||||||
|
await expect(stories).toHaveCount(1);
|
||||||
|
|
||||||
|
const guardToggle = page.getByRole("button", {
|
||||||
|
name: "Block Stories re-entry",
|
||||||
|
});
|
||||||
|
await guardToggle.click();
|
||||||
|
await expect(guardToggle).toHaveAttribute("aria-pressed", "true");
|
||||||
|
await page.getByRole("link", { name: /Stories/ }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/profile$/);
|
||||||
|
await expect(page.getByTestId("story-guard-status")).toHaveText("blocked");
|
||||||
|
await waitForTransition(page);
|
||||||
|
await expect(page.getByTestId("stories-view")).toHaveCount(0);
|
||||||
|
|
||||||
|
await guardToggle.click();
|
||||||
|
await page.getByRole("link", { name: /Stories/ }).click();
|
||||||
|
await expect(page).toHaveURL(/\/stories$/);
|
||||||
|
await waitForTransition(page);
|
||||||
|
await expect(page.getByTestId("stories-view")).not.toHaveAttribute(
|
||||||
|
"data-mount-id",
|
||||||
|
firstMountId!,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("manually unloads an inactive route through the public API demo", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/stories");
|
||||||
|
await page.getByRole("link", { name: /You/ }).click();
|
||||||
|
await expect(page).toHaveURL(/\/profile$/);
|
||||||
|
await waitForTransition(page);
|
||||||
|
await expect(page.getByTestId("stories-view")).toHaveCount(1);
|
||||||
|
|
||||||
|
await page.getByRole("link", { name: /Navigation lab/ }).click();
|
||||||
|
await expect(page).toHaveURL(/\/settings$/);
|
||||||
|
await waitForTransition(page);
|
||||||
|
await page.getByTestId("unload-stories").click();
|
||||||
|
|
||||||
|
await expect(page.getByTestId("unload-stories")).toHaveText(
|
||||||
|
"Unloaded 1 Stories view",
|
||||||
|
);
|
||||||
|
await expect(page.getByTestId("stories-view")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exercises Vue built-ins, lifecycle hooks, injection, and scoped route state", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/profile");
|
||||||
|
await page.getByRole("link", { name: /Vue compatibility lab/ }).click();
|
||||||
|
await expect(page).toHaveURL(
|
||||||
|
/\/profile\/vue-lab\/alpha\?mode=manual#route-state$/,
|
||||||
|
);
|
||||||
|
|
||||||
|
const lab = page.getByTestId("vue-compatibility-view");
|
||||||
|
const firstInstance = await lab.getAttribute("data-instance-id");
|
||||||
|
await expect(page.getByTestId("compat-param")).toHaveText("alpha");
|
||||||
|
await expect(page.getByTestId("compat-query")).toContainText("manual");
|
||||||
|
await expect(page.getByTestId("compat-hash")).toHaveText("#route-state");
|
||||||
|
await expect(page.getByTestId("options-lifecycle-probe")).toContainText(
|
||||||
|
"/profile/vue-lab/alpha?mode=manual#route-state",
|
||||||
|
);
|
||||||
|
await expect(page.getByTestId("inject-probe-route-tree")).toContainText(
|
||||||
|
"Injected from the demo application root",
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Increment A" }).click();
|
||||||
|
await expect(page.getByTestId("keep-alive-A")).toContainText("Counter: 1");
|
||||||
|
await page.getByTestId("compat-switch-keepalive").click();
|
||||||
|
await expect(page.getByTestId("keep-alive-B")).toBeVisible();
|
||||||
|
await page.getByTestId("compat-switch-keepalive").click();
|
||||||
|
await expect(page.getByTestId("keep-alive-A")).toContainText("Counter: 1");
|
||||||
|
|
||||||
|
await page.getByTestId("compat-update-probes").click();
|
||||||
|
await expect(page.getByTestId("compat-event-log")).toContainText("onUpdated");
|
||||||
|
await page.getByTestId("compat-toggle-transition").click();
|
||||||
|
await expect(page.getByTestId("compat-transition-card")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("compat-event-log")).toContainText(
|
||||||
|
"after-leave",
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.getByTestId("compat-open-teleport").click();
|
||||||
|
const teleport = page.getByTestId("compat-teleport-overlay");
|
||||||
|
await expect(teleport).toBeVisible();
|
||||||
|
await expect(page.getByTestId("inject-probe-teleport")).toContainText(
|
||||||
|
"Injected from the demo application root",
|
||||||
|
);
|
||||||
|
await page.getByRole("button", { name: "Close teleported overlay" }).click();
|
||||||
|
|
||||||
|
await page.getByTestId("compat-reload-suspense").click();
|
||||||
|
await expect(page.getByTestId("compat-suspense-fallback")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("compat-suspense-ready")).toBeVisible({
|
||||||
|
timeout: 2_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByTestId("compat-open-away").click();
|
||||||
|
await expect(page.getByTestId("vue-compatibility-away")).toBeVisible();
|
||||||
|
await page.getByTestId("compat-unload-return").click();
|
||||||
|
await expect(page.getByTestId("vue-compatibility-view")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("vue-compatibility-view")).not.toHaveAttribute(
|
||||||
|
"data-instance-id",
|
||||||
|
firstInstance!,
|
||||||
|
);
|
||||||
|
await expect(page.getByTestId("compat-event-log")).toContainText(
|
||||||
|
"onUnmounted",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("records a navigation frame profile across route changes", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/settings");
|
||||||
|
await page.getByTestId("profile-start").click();
|
||||||
|
await expect(page.locator(".profiler-badge")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Back" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/profile$/);
|
||||||
|
await waitForTransition(page);
|
||||||
|
await page.getByRole("link", { name: /Runtime stress lab/ }).click();
|
||||||
|
await expect(page).toHaveURL(/\/profile\/runtime-lab$/);
|
||||||
|
await page.getByRole("link", { name: /You/ }).click();
|
||||||
|
await expect(page).toHaveURL(/\/profile$/);
|
||||||
|
await waitForTransition(page);
|
||||||
|
await page.getByRole("link", { name: /Navigation lab/ }).click();
|
||||||
|
await expect(page).toHaveURL(/\/settings$/);
|
||||||
|
await waitForTransition(page);
|
||||||
|
|
||||||
|
await page.getByTestId("profile-stop").click();
|
||||||
|
await expect(page.locator(".profiler-badge")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("profile-status")).toContainText("navigations");
|
||||||
|
await expect(page.getByTestId("profile-export")).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("opens and dismisses the compose sheet", async ({ page }) => {
|
||||||
|
await page.goto("/inbox");
|
||||||
|
await page.getByRole("button", { name: "Compose" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/compose$/);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "New message" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await waitForTransition(page);
|
||||||
|
await expect(page.locator("[data-native-sheet]")).toHaveAttribute(
|
||||||
|
"data-native-sheet-breakpoint",
|
||||||
|
"0.62",
|
||||||
|
);
|
||||||
|
await expect(page.locator('[data-native-role="underlay"]')).toHaveAttribute(
|
||||||
|
"aria-hidden",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
await page.getByRole("button", { name: "Cancel" }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "Messages" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contains a sheet below the safe top and supports snap and content sizes", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/inbox");
|
||||||
|
await page.getByRole("button", { name: "Compose" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/compose$/);
|
||||||
|
await waitForTransition(page);
|
||||||
|
|
||||||
|
const routerView = page.locator(".nvr-router-view");
|
||||||
|
const activeLayer = page.locator('[data-native-role="active"]');
|
||||||
|
const frame = await routerView.boundingBox();
|
||||||
|
const layer = await activeLayer.boundingBox();
|
||||||
|
if (!frame || !layer) throw new Error("Sheet route did not render");
|
||||||
|
expect(layer.y).toBeGreaterThanOrEqual(frame.y + 7);
|
||||||
|
expect(layer.y + layer.height).toBeLessThanOrEqual(
|
||||||
|
frame.y + frame.height + 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handle = page.getByRole("slider", {
|
||||||
|
name: "Resize or dismiss sheet",
|
||||||
|
});
|
||||||
|
await handle.press("ArrowUp");
|
||||||
|
await expect(page.getByTestId("sheet-size")).toHaveText("100%");
|
||||||
|
await expect(page.locator("[data-native-sheet]")).toHaveAttribute(
|
||||||
|
"data-native-sheet-breakpoint",
|
||||||
|
"1",
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Fit content" }).click();
|
||||||
|
await expect(page.getByTestId("sheet-size")).toHaveText("Auto");
|
||||||
|
await expect(page.locator("[data-native-sheet]")).toHaveAttribute(
|
||||||
|
"data-native-sheet-mode",
|
||||||
|
"content",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("animates the partial surface immediately and preserves underlay geometry", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/inbox");
|
||||||
|
await page.getByRole("button", { name: "Compose" }).dispatchEvent("click");
|
||||||
|
|
||||||
|
const opening = await page.evaluate(async () => {
|
||||||
|
for (let frame = 0; frame < 60; frame += 1) {
|
||||||
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||||
|
const routerView = document.querySelector<HTMLElement>(
|
||||||
|
".nvr-router-view--interactive",
|
||||||
|
);
|
||||||
|
const surface = document.querySelector<HTMLElement>(
|
||||||
|
'[data-native-role="to"] [data-native-sheet]',
|
||||||
|
);
|
||||||
|
const progress = Number(
|
||||||
|
routerView?.style.getPropertyValue("--native-progress") ?? 0,
|
||||||
|
);
|
||||||
|
if (routerView && surface && progress >= 0.03) {
|
||||||
|
const frameRect = routerView.getBoundingClientRect();
|
||||||
|
const surfaceRect = surface.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
progress,
|
||||||
|
frameBottom: frameRect.bottom,
|
||||||
|
surfaceTop: surfaceRect.top,
|
||||||
|
surfaceHeight: surfaceRect.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
expect(opening).toBeDefined();
|
||||||
|
expect(opening!.surfaceTop).toBeLessThan(opening!.frameBottom);
|
||||||
|
|
||||||
|
await waitForTransition(page);
|
||||||
|
const settledSurfaceHeight = await page
|
||||||
|
.locator("[data-native-sheet]")
|
||||||
|
.evaluate((element) => element.getBoundingClientRect().height);
|
||||||
|
expect(opening!.surfaceHeight).toBeCloseTo(settledSurfaceHeight, 0);
|
||||||
|
const openUnderlay = await page
|
||||||
|
.locator('[data-native-role="underlay"]')
|
||||||
|
.evaluate((element) => {
|
||||||
|
const matrix = new DOMMatrix(getComputedStyle(element).transform);
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return { scale: matrix.a, top: rect.top };
|
||||||
|
});
|
||||||
|
expect(openUnderlay.scale).toBeCloseTo(0.96, 2);
|
||||||
|
expect(openUnderlay.top).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Cancel" }).dispatchEvent("click");
|
||||||
|
const closing = await page.evaluate(async () => {
|
||||||
|
for (let frame = 0; frame < 60; frame += 1) {
|
||||||
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||||
|
const routerView = document.querySelector<HTMLElement>(
|
||||||
|
'.nvr-router-view--interactive[data-native-direction="back"]',
|
||||||
|
);
|
||||||
|
const destination = document.querySelector<HTMLElement>(
|
||||||
|
'[data-native-role="to"]',
|
||||||
|
);
|
||||||
|
const progress = Number(
|
||||||
|
routerView?.style.getPropertyValue("--native-progress") ?? 0,
|
||||||
|
);
|
||||||
|
if (routerView && destination && progress >= 0.03) {
|
||||||
|
const matrix = new DOMMatrix(getComputedStyle(destination).transform);
|
||||||
|
return { progress, scale: matrix.a };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
expect(closing).toBeDefined();
|
||||||
|
expect(closing!.scale).toBeGreaterThanOrEqual(0.96);
|
||||||
|
expect(closing!.scale).toBeLessThan(1);
|
||||||
|
await waitForTransition(page);
|
||||||
|
await expect(page).toHaveURL(/\/inbox$/);
|
||||||
|
const restoredScale = await page
|
||||||
|
.locator('[data-native-role="active"]')
|
||||||
|
.evaluate(
|
||||||
|
(element) => new DOMMatrix(getComputedStyle(element).transform).a,
|
||||||
|
);
|
||||||
|
expect(restoredScale).toBeCloseTo(1, 3);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Compose" }).dispatchEvent("click");
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
const routerView = document.querySelector<HTMLElement>(
|
||||||
|
'.nvr-router-view--interactive[data-native-presentation="sheet"]',
|
||||||
|
);
|
||||||
|
const surface = document.querySelector<HTMLElement>(
|
||||||
|
'[data-native-role="to"] [data-native-sheet]',
|
||||||
|
);
|
||||||
|
return Boolean(
|
||||||
|
routerView &&
|
||||||
|
surface &&
|
||||||
|
surface.getBoundingClientRect().top <
|
||||||
|
routerView.getBoundingClientRect().bottom,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await waitForTransition(page);
|
||||||
|
const repeatedUnderlayScale = await page
|
||||||
|
.locator('[data-native-role="underlay"]')
|
||||||
|
.evaluate(
|
||||||
|
(element) => new DOMMatrix(getComputedStyle(element).transform).a,
|
||||||
|
);
|
||||||
|
expect(repeatedUnderlayScale).toBeCloseTo(0.96, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hands content overscroll to adjacent sheet breakpoints", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/inbox");
|
||||||
|
await page.getByRole("button", { name: "Compose" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/compose$/);
|
||||||
|
await waitForTransition(page);
|
||||||
|
|
||||||
|
const surface = page.locator("[data-native-sheet]");
|
||||||
|
const body = page.locator(".nvr-sheet__body");
|
||||||
|
const box = await body.boundingBox();
|
||||||
|
if (!box) throw new Error("Sheet scroll body did not render");
|
||||||
|
|
||||||
|
await body.evaluate((element) => {
|
||||||
|
element.scrollTop = Math.min(
|
||||||
|
40,
|
||||||
|
Math.max(0, element.scrollHeight - element.clientHeight - 10),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await page.mouse.move(box.x + 100, box.y + box.height * 0.5);
|
||||||
|
await page.mouse.wheel(0, 1_000);
|
||||||
|
await page.mouse.wheel(0, 80);
|
||||||
|
await expect(surface).toHaveAttribute("data-native-sheet-breakpoint", "0.62");
|
||||||
|
await page.waitForTimeout(280);
|
||||||
|
|
||||||
|
await body.evaluate((element) => {
|
||||||
|
element.scrollTop = element.scrollHeight;
|
||||||
|
});
|
||||||
|
await page.mouse.move(box.x + 100, box.y + box.height * 0.7);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(box.x + 100, box.y + box.height * 0.35, { steps: 10 });
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect(surface).toHaveAttribute("data-native-sheet-breakpoint", "1");
|
||||||
|
|
||||||
|
await body.evaluate((element) => {
|
||||||
|
element.scrollTop = 0;
|
||||||
|
});
|
||||||
|
const expandedBox = await body.boundingBox();
|
||||||
|
if (!expandedBox) throw new Error("Expanded sheet body did not render");
|
||||||
|
await page.mouse.move(
|
||||||
|
expandedBox.x + 100,
|
||||||
|
expandedBox.y + expandedBox.height * 0.35,
|
||||||
|
);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(
|
||||||
|
expandedBox.x + 100,
|
||||||
|
expandedBox.y + expandedBox.height * 0.55,
|
||||||
|
{ steps: 10 },
|
||||||
|
);
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect(surface).toHaveAttribute("data-native-sheet-breakpoint", "0.62");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the target live during a held component drag", async ({ page }) => {
|
||||||
|
await page.goto("/inbox");
|
||||||
|
const row = page.locator(".conversation-row").first();
|
||||||
|
const box = await row.boundingBox();
|
||||||
|
if (!box) throw new Error("Conversation row did not render");
|
||||||
|
await page.mouse.move(box.x + box.width * 0.8, box.y + box.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(box.x + box.width * 0.35, box.y + box.height / 2, {
|
||||||
|
steps: 12,
|
||||||
|
});
|
||||||
|
await expect(page.locator('[data-native-role="to"]')).toBeVisible();
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect(page.getByRole("heading", { name: "Maya Chen" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("holds an edge-back preview without committing the URL", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/inbox");
|
||||||
|
await page.getByText("Maya Chen").last().click();
|
||||||
|
await expect(page).toHaveURL(/\/chat\/maya$/);
|
||||||
|
const frame = await page.locator(".app-frame").boundingBox();
|
||||||
|
if (!frame) throw new Error("App frame did not render");
|
||||||
|
await page.mouse.move(frame.x + 2, frame.y + frame.height * 0.5);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(
|
||||||
|
frame.x + frame.width * 0.55,
|
||||||
|
frame.y + frame.height * 0.5,
|
||||||
|
{ steps: 14 },
|
||||||
|
);
|
||||||
|
await expect(page.locator('[data-native-role="to"]')).toBeVisible();
|
||||||
|
await expect(page).toHaveURL(/\/chat\/maya$/);
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect(page.getByRole("heading", { name: "Messages" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("never previews a stale conversation after a cancelled back gesture", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/inbox");
|
||||||
|
await page.getByText("Maya Chen").last().click();
|
||||||
|
await page.getByRole("button", { name: "Back" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/inbox$/);
|
||||||
|
await waitForTransition(page);
|
||||||
|
await page.getByText("Noah Williams").last().click();
|
||||||
|
await expect(page).toHaveURL(/\/chat\/noah$/);
|
||||||
|
await waitForTransition(page);
|
||||||
|
|
||||||
|
const frame = await page.locator(".app-frame").boundingBox();
|
||||||
|
if (!frame) throw new Error("App frame did not render");
|
||||||
|
await page.mouse.move(frame.x + 2, frame.y + frame.height * 0.5);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(
|
||||||
|
frame.x + frame.width * 0.055,
|
||||||
|
frame.y + frame.height * 0.5,
|
||||||
|
{ steps: 16 },
|
||||||
|
);
|
||||||
|
await page.mouse.up();
|
||||||
|
await waitForTransition(page);
|
||||||
|
await expect(page).toHaveURL(/\/chat\/noah$/);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Back" }).click();
|
||||||
|
const backTarget = page.locator('[data-native-role="to"]');
|
||||||
|
await expect(
|
||||||
|
backTarget.getByRole("heading", { name: "Messages" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
backTarget.getByRole("heading", { name: "Maya Chen" }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
await waitForTransition(page);
|
||||||
|
await expect(page).toHaveURL(/\/inbox$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("always opens compose as a vertical sheet after prior navigation", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/inbox");
|
||||||
|
await page.getByText("Maya Chen").last().click();
|
||||||
|
await page.getByRole("button", { name: "Back" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/inbox$/);
|
||||||
|
await waitForTransition(page);
|
||||||
|
await page.getByRole("link", { name: /Stories/ }).click();
|
||||||
|
await expect(page).toHaveURL(/\/stories$/);
|
||||||
|
await waitForTransition(page);
|
||||||
|
await page.getByRole("link", { name: /Inbox/ }).click();
|
||||||
|
await expect(page).toHaveURL(/\/inbox$/);
|
||||||
|
await waitForTransition(page);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Compose" }).click();
|
||||||
|
const routerView = page.locator(".nvr-router-view");
|
||||||
|
await expect(routerView).toHaveAttribute("data-native-presentation", "sheet");
|
||||||
|
await expect(routerView).toHaveAttribute("data-native-direction", "up");
|
||||||
|
const frame = await routerView.boundingBox();
|
||||||
|
const sheet = await page.locator('[data-native-role="to"]').boundingBox();
|
||||||
|
if (!frame || !sheet) throw new Error("Sheet transition did not render");
|
||||||
|
expect(Math.abs(frame.x - sheet.x)).toBeLessThan(3);
|
||||||
|
await waitForTransition(page);
|
||||||
|
await page.getByRole("button", { name: "Cancel" }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("drags a sheet down to dismiss it", async ({ page }) => {
|
||||||
|
await page.goto("/inbox");
|
||||||
|
await page.getByRole("button", { name: "Compose" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/compose$/);
|
||||||
|
await waitForTransition(page);
|
||||||
|
const sheet = await page.locator("[data-native-sheet]").boundingBox();
|
||||||
|
const handle = await page.locator(".nvr-sheet__handle").boundingBox();
|
||||||
|
if (!sheet || !handle) throw new Error("Sheet did not render");
|
||||||
|
await page.mouse.move(handle.x + handle.width / 2, handle.y + 12);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(
|
||||||
|
handle.x + handle.width / 2,
|
||||||
|
handle.y + sheet.height * 0.75,
|
||||||
|
{ steps: 14 },
|
||||||
|
);
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect(page.getByRole("heading", { name: "Messages" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|||||||
157
apps/demo/e2e/pwa.spec.ts
Normal file
157
apps/demo/e2e/pwa.spec.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
const iphoneUserAgent =
|
||||||
|
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 Version/18.5 Mobile/15E148 Safari/604.1";
|
||||||
|
|
||||||
|
async function dispatchTouchStart(
|
||||||
|
page: import("@playwright/test").Page,
|
||||||
|
clientX: number,
|
||||||
|
) {
|
||||||
|
return await page.locator(".nvr-navigator").evaluate((element, x) => {
|
||||||
|
const event = new Event("touchstart", { bubbles: true, cancelable: true });
|
||||||
|
Object.defineProperty(event, "touches", {
|
||||||
|
value: [{ identifier: 7, clientX: x, clientY: 240 }],
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
dispatched: element.dispatchEvent(event),
|
||||||
|
prevented: event.defaultPrevented,
|
||||||
|
};
|
||||||
|
}, clientX);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("ships an installable standalone manifest and iOS metadata", async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/inbox");
|
||||||
|
await expect(
|
||||||
|
page.locator('meta[name="apple-mobile-web-app-capable"]'),
|
||||||
|
).toHaveAttribute("content", "yes");
|
||||||
|
await expect(
|
||||||
|
page.locator('meta[name="apple-mobile-web-app-status-bar-style"]'),
|
||||||
|
).toHaveAttribute("content", "black-translucent");
|
||||||
|
const touchIconPath = await page
|
||||||
|
.locator('link[rel="apple-touch-icon"]')
|
||||||
|
.evaluate(
|
||||||
|
(link) =>
|
||||||
|
new URL(link.getAttribute("href") ?? "", document.baseURI).pathname,
|
||||||
|
);
|
||||||
|
expect(touchIconPath).toBe("/apple-touch-icon.png");
|
||||||
|
|
||||||
|
const manifestResponse = await request.get("/manifest.webmanifest");
|
||||||
|
expect(manifestResponse.ok()).toBe(true);
|
||||||
|
const manifest = await manifestResponse.json();
|
||||||
|
expect(manifest).toMatchObject({
|
||||||
|
id: "/",
|
||||||
|
scope: "/",
|
||||||
|
start_url: "/",
|
||||||
|
display: "standalone",
|
||||||
|
});
|
||||||
|
expect(manifest.icons).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
src: "/pwa-192.png",
|
||||||
|
sizes: "192x192",
|
||||||
|
type: "image/png",
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
src: "/pwa-512.png",
|
||||||
|
sizes: "512x512",
|
||||||
|
type: "image/png",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await request.get("/apple-touch-icon.png")).headers()["content-type"],
|
||||||
|
).toContain("image/png");
|
||||||
|
const workerResponse = await request.get("/sw.js");
|
||||||
|
expect(workerResponse.ok()).toBe(true);
|
||||||
|
expect(workerResponse.headers()["cache-control"]).toContain("no-cache");
|
||||||
|
const worker = await workerResponse.text();
|
||||||
|
expect(worker).toContain("self.skipWaiting()");
|
||||||
|
expect(worker).toContain("clientsClaim()");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not interfere with Safari edge touches before Home Screen installation", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.addInitScript((userAgent) => {
|
||||||
|
Object.defineProperty(navigator, "userAgent", {
|
||||||
|
configurable: true,
|
||||||
|
value: userAgent,
|
||||||
|
});
|
||||||
|
Object.defineProperty(navigator, "standalone", {
|
||||||
|
configurable: true,
|
||||||
|
value: false,
|
||||||
|
});
|
||||||
|
}, iphoneUserAgent);
|
||||||
|
await page.goto("/chat/maya");
|
||||||
|
await expect(page.locator("html")).toHaveAttribute(
|
||||||
|
"data-pwa-display-mode",
|
||||||
|
"browser",
|
||||||
|
);
|
||||||
|
await expect(page.locator("html")).toHaveAttribute(
|
||||||
|
"data-pwa-edge-guard",
|
||||||
|
"inactive",
|
||||||
|
);
|
||||||
|
expect((await dispatchTouchStart(page, 1)).prevented).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reserves only the leading edge in an installed iOS PWA", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.addInitScript((userAgent) => {
|
||||||
|
Object.defineProperty(navigator, "userAgent", {
|
||||||
|
configurable: true,
|
||||||
|
value: userAgent,
|
||||||
|
});
|
||||||
|
Object.defineProperty(navigator, "standalone", {
|
||||||
|
configurable: true,
|
||||||
|
value: true,
|
||||||
|
});
|
||||||
|
}, iphoneUserAgent);
|
||||||
|
await page.goto("/chat/maya");
|
||||||
|
const root = page.locator("html");
|
||||||
|
await expect(root).toHaveAttribute("data-pwa-platform", "ios");
|
||||||
|
await expect(root).toHaveAttribute("data-pwa-display-mode", "standalone");
|
||||||
|
await expect(root).toHaveAttribute("data-pwa-edge-guard", "active");
|
||||||
|
|
||||||
|
expect((await dispatchTouchStart(page, 80)).prevented).toBe(false);
|
||||||
|
expect((await dispatchTouchStart(page, 1)).prevented).toBe(true);
|
||||||
|
await expect(root).toHaveAttribute("data-pwa-edge-claims", "1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("registers and activates the offline service worker", async ({ page }) => {
|
||||||
|
await page.goto("/inbox");
|
||||||
|
const workerUrl = await page.evaluate(async () => {
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
return registration.active?.scriptURL ?? "";
|
||||||
|
});
|
||||||
|
expect(workerUrl).toMatch(/\/sw\.js$/);
|
||||||
|
await expect(page.locator("html")).not.toHaveAttribute(
|
||||||
|
"data-pwa-update-checks",
|
||||||
|
"0",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("precaches lazily split routes for offline navigation", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/inbox");
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
await navigator.serviceWorker.ready;
|
||||||
|
});
|
||||||
|
const cachedUrls = await page.evaluate(async () => {
|
||||||
|
const urls: string[] = [];
|
||||||
|
for (const name of await caches.keys()) {
|
||||||
|
const cache = await caches.open(name);
|
||||||
|
urls.push(...(await cache.keys()).map((request) => request.url));
|
||||||
|
}
|
||||||
|
return urls;
|
||||||
|
});
|
||||||
|
expect(cachedUrls.some((url) => /StoriesView-.*\.js$/.test(url))).toBe(true);
|
||||||
|
expect(cachedUrls.some((url) => /ProfileView-.*\.js$/.test(url))).toBe(true);
|
||||||
|
expect(cachedUrls.some((url) => /RuntimeLabView-.*\.js$/.test(url))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -8,6 +8,15 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="NVR Messenger" />
|
<meta name="apple-mobile-web-app-title" content="NVR Messenger" />
|
||||||
<meta name="format-detection" content="telephone=no" />
|
<meta name="format-detection" content="telephone=no" />
|
||||||
|
<script>
|
||||||
|
// Vite emits relative assets for the packaged Electron file:// build.
|
||||||
|
// Web/PWA deep links need those same URLs rooted at the HTTPS origin.
|
||||||
|
if (location.protocol !== 'file:') {
|
||||||
|
const base = document.createElement('base')
|
||||||
|
base.href = '/'
|
||||||
|
document.head.append(base)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
<link rel="icon" href="/favicon.svg" />
|
<link rel="icon" href="/favicon.svg" />
|
||||||
<title>Native Vue Messenger</title>
|
<title>Native Vue Messenger</title>
|
||||||
|
|||||||
@@ -4,14 +4,15 @@ import { NativeNavigator, NativeRouterView } from '@native-vue-router/core'
|
|||||||
import { NativeTabBar, type NativeTabItem } from '@native-vue-router/preset-native'
|
import { NativeTabBar, type NativeTabItem } from '@native-vue-router/preset-native'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import PwaUpdate from './components/PwaUpdate.vue'
|
import PwaUpdate from './components/PwaUpdate.vue'
|
||||||
|
import { profilerRecording } from './navigation-profiler'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const siblingRoutes = ['/inbox', '/stories', '/profile']
|
const siblingRoutes = ['/inbox', '/stories', '/profile', '/profile/runtime-lab']
|
||||||
const showTabs = computed(() => Boolean(route.meta.tab))
|
const showTabs = computed(() => Boolean(route.meta.tab))
|
||||||
const tabs: NativeTabItem[] = [
|
const tabs: NativeTabItem[] = [
|
||||||
{ label: 'Inbox', to: '/inbox', icon: '◉' },
|
{ label: 'Inbox', to: '/inbox', icon: '◉' },
|
||||||
{ label: 'Stories', to: '/stories', icon: '◎' },
|
{ label: 'Stories', to: '/stories', icon: '◎' },
|
||||||
{ label: 'You', to: '/profile', icon: '◇' },
|
{ label: 'You', to: '/profile', icon: '◇', activeWhen: (current) => current.path.startsWith('/profile') },
|
||||||
]
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ const tabs: NativeTabItem[] = [
|
|||||||
<NativeRouterView />
|
<NativeRouterView />
|
||||||
</NativeNavigator>
|
</NativeNavigator>
|
||||||
<NativeTabBar v-if="showTabs" :items="tabs" class="app-tabs" />
|
<NativeTabBar v-if="showTabs" :items="tabs" class="app-tabs" />
|
||||||
|
<div v-if="profilerRecording" class="profiler-badge" aria-live="polite"><i /> Profiling navigation</div>
|
||||||
<PwaUpdate />
|
<PwaUpdate />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
44
apps/demo/src/compatibility-lab.ts
Normal file
44
apps/demo/src/compatibility-lab.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { reactive, type InjectionKey, type Ref } from "vue";
|
||||||
|
|
||||||
|
export interface CompatibilityLabEvent {
|
||||||
|
id: number;
|
||||||
|
timestamp: string;
|
||||||
|
source: string;
|
||||||
|
hook: string;
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompatibilityLabContext {
|
||||||
|
source: string;
|
||||||
|
routeLabel: Readonly<Ref<string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const demoAppValueKey: InjectionKey<string> = Symbol("demo-app-value");
|
||||||
|
export const compatibilityLabContextKey: InjectionKey<CompatibilityLabContext> =
|
||||||
|
Symbol("compatibility-lab-context");
|
||||||
|
|
||||||
|
let eventSequence = 0;
|
||||||
|
const startedAt = performance.now();
|
||||||
|
|
||||||
|
export const compatibilityLab = reactive({
|
||||||
|
events: [] as CompatibilityLabEvent[],
|
||||||
|
});
|
||||||
|
|
||||||
|
export function recordCompatibilityEvent(
|
||||||
|
source: string,
|
||||||
|
hook: string,
|
||||||
|
detail?: string,
|
||||||
|
) {
|
||||||
|
compatibilityLab.events.unshift({
|
||||||
|
id: ++eventSequence,
|
||||||
|
timestamp: `${(performance.now() - startedAt).toFixed(0)} ms`,
|
||||||
|
source,
|
||||||
|
hook,
|
||||||
|
detail,
|
||||||
|
});
|
||||||
|
if (compatibilityLab.events.length > 120) compatibilityLab.events.splice(120);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearCompatibilityEvents() {
|
||||||
|
compatibilityLab.events.splice(0);
|
||||||
|
}
|
||||||
16
apps/demo/src/components/AsyncLabData.vue
Normal file
16
apps/demo/src/components/AsyncLabData.vue
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const requestedAt = Date.now()
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, 3_000))
|
||||||
|
const resolutionTime = Date.now() - requestedAt
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article class="lab-probe lab-probe--ready" data-testid="async-data-ready">
|
||||||
|
<span class="lab-probe__icon" aria-hidden="true">✓</span>
|
||||||
|
<div>
|
||||||
|
<strong>Async payload available</strong>
|
||||||
|
<p>Resolved {{ resolutionTime }} ms after this component mounted.</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
39
apps/demo/src/components/CompatibilityAsyncProbe.vue
Normal file
39
apps/demo/src/components/CompatibilityAsyncProbe.vue
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { inject } from "vue";
|
||||||
|
import { useRoute } from "vue-router";
|
||||||
|
import {
|
||||||
|
compatibilityLabContextKey,
|
||||||
|
demoAppValueKey,
|
||||||
|
recordCompatibilityEvent,
|
||||||
|
} from "../compatibility-lab";
|
||||||
|
|
||||||
|
const props = defineProps<{ requestId: number }>();
|
||||||
|
const route = useRoute();
|
||||||
|
const appValue = inject(demoAppValueKey, "missing app injection");
|
||||||
|
const labContext = inject(compatibilityLabContextKey);
|
||||||
|
|
||||||
|
recordCompatibilityEvent(
|
||||||
|
"Suspense",
|
||||||
|
"async setup started",
|
||||||
|
`request ${props.requestId}`,
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, 750));
|
||||||
|
recordCompatibilityEvent(
|
||||||
|
"Suspense",
|
||||||
|
"async setup resolved",
|
||||||
|
`request ${props.requestId}`,
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article
|
||||||
|
class="compat-probe compat-probe--resolved"
|
||||||
|
data-testid="compat-suspense-ready"
|
||||||
|
>
|
||||||
|
<span class="compat-probe__badge">Suspense resolved</span>
|
||||||
|
<strong>Async request {{ requestId }} complete</strong>
|
||||||
|
<p>{{ appValue }}</p>
|
||||||
|
<p>{{ labContext?.routeLabel.value }}</p>
|
||||||
|
<p>{{ route.fullPath }}</p>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
41
apps/demo/src/components/CompatibilityCompositionProbe.vue
Normal file
41
apps/demo/src/components/CompatibilityCompositionProbe.vue
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
onBeforeMount,
|
||||||
|
onBeforeUnmount,
|
||||||
|
onBeforeUpdate,
|
||||||
|
onMounted,
|
||||||
|
onUnmounted,
|
||||||
|
onUpdated,
|
||||||
|
ref,
|
||||||
|
} from "vue";
|
||||||
|
import { useRoute } from "vue-router";
|
||||||
|
import { recordCompatibilityEvent } from "../compatibility-lab";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
instanceName: string;
|
||||||
|
revision: number;
|
||||||
|
}>();
|
||||||
|
const route = useRoute();
|
||||||
|
const localCount = ref(0);
|
||||||
|
const record = (hook: string) =>
|
||||||
|
recordCompatibilityEvent(props.instanceName, hook, route.fullPath);
|
||||||
|
|
||||||
|
onBeforeMount(() => record("onBeforeMount"));
|
||||||
|
onMounted(() => record("onMounted"));
|
||||||
|
onBeforeUpdate(() => record("onBeforeUpdate"));
|
||||||
|
onUpdated(() => record("onUpdated"));
|
||||||
|
onBeforeUnmount(() => record("onBeforeUnmount"));
|
||||||
|
onUnmounted(() => record("onUnmounted"));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article class="compat-probe" data-testid="composition-lifecycle-probe">
|
||||||
|
<span class="compat-probe__badge">Composition API</span>
|
||||||
|
<strong>{{ instanceName }}</strong>
|
||||||
|
<p><code>useRoute()</code>: {{ route.fullPath }}</p>
|
||||||
|
<p>Revision {{ revision }} · local count {{ localCount }}</p>
|
||||||
|
<button type="button" @click="localCount += 1">
|
||||||
|
Increment local state
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
29
apps/demo/src/components/CompatibilityInjectProbe.vue
Normal file
29
apps/demo/src/components/CompatibilityInjectProbe.vue
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, inject } from "vue";
|
||||||
|
import { useRoute } from "vue-router";
|
||||||
|
import {
|
||||||
|
compatibilityLabContextKey,
|
||||||
|
demoAppValueKey,
|
||||||
|
} from "../compatibility-lab";
|
||||||
|
|
||||||
|
defineProps<{ location: "route tree" | "teleport" }>();
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const appValue = inject(demoAppValueKey, "missing app injection");
|
||||||
|
const labContext = inject(compatibilityLabContextKey);
|
||||||
|
const labValue = computed(
|
||||||
|
() => labContext?.routeLabel.value ?? "missing page injection",
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article
|
||||||
|
class="compat-probe"
|
||||||
|
:data-testid="`inject-probe-${location.replace(' ', '-')}`"
|
||||||
|
>
|
||||||
|
<span class="compat-probe__badge">provide / inject · {{ location }}</span>
|
||||||
|
<strong>{{ appValue }}</strong>
|
||||||
|
<p>Page injection: {{ labValue }}</p>
|
||||||
|
<p>Scoped route: {{ route.fullPath }}</p>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
36
apps/demo/src/components/CompatibilityKeepAliveProbe.vue
Normal file
36
apps/demo/src/components/CompatibilityKeepAliveProbe.vue
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
onActivated,
|
||||||
|
onBeforeMount,
|
||||||
|
onBeforeUnmount,
|
||||||
|
onDeactivated,
|
||||||
|
onMounted,
|
||||||
|
onUnmounted,
|
||||||
|
ref,
|
||||||
|
} from "vue";
|
||||||
|
import { recordCompatibilityEvent } from "../compatibility-lab";
|
||||||
|
|
||||||
|
const props = defineProps<{ name: string }>();
|
||||||
|
const count = ref(0);
|
||||||
|
const record = (hook: string) =>
|
||||||
|
recordCompatibilityEvent(`KeepAlive ${props.name}`, hook);
|
||||||
|
|
||||||
|
onBeforeMount(() => record("onBeforeMount"));
|
||||||
|
onMounted(() => record("onMounted"));
|
||||||
|
onActivated(() => record("onActivated"));
|
||||||
|
onDeactivated(() => record("onDeactivated"));
|
||||||
|
onBeforeUnmount(() => record("onBeforeUnmount"));
|
||||||
|
onUnmounted(() => record("onUnmounted"));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article
|
||||||
|
class="compat-probe compat-probe--keepalive"
|
||||||
|
:data-testid="`keep-alive-${name}`"
|
||||||
|
>
|
||||||
|
<span class="compat-probe__badge">Kept instance {{ name }}</span>
|
||||||
|
<strong>Counter: {{ count }}</strong>
|
||||||
|
<p>Increment, switch instances, then return to verify preserved state.</p>
|
||||||
|
<button type="button" @click="count += 1">Increment {{ name }}</button>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
56
apps/demo/src/components/CompatibilityOptionsProbe.vue
Normal file
56
apps/demo/src/components/CompatibilityOptionsProbe.vue
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent } from "vue";
|
||||||
|
import type { RouteLocationNormalizedLoaded } from "vue-router";
|
||||||
|
import { recordCompatibilityEvent } from "../compatibility-lab";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: "CompatibilityOptionsProbe",
|
||||||
|
props: {
|
||||||
|
instanceName: { type: String, required: true },
|
||||||
|
revision: { type: Number, required: true },
|
||||||
|
},
|
||||||
|
data: () => ({ localCount: 0 }),
|
||||||
|
beforeCreate() {
|
||||||
|
recordCompatibilityEvent(this.instanceName, "beforeCreate");
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
recordCompatibilityEvent(this.instanceName, "created", this.routePath());
|
||||||
|
},
|
||||||
|
beforeMount() {
|
||||||
|
recordCompatibilityEvent(this.instanceName, "beforeMount");
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
recordCompatibilityEvent(this.instanceName, "mounted");
|
||||||
|
},
|
||||||
|
beforeUpdate() {
|
||||||
|
recordCompatibilityEvent(this.instanceName, "beforeUpdate");
|
||||||
|
},
|
||||||
|
updated() {
|
||||||
|
recordCompatibilityEvent(this.instanceName, "updated");
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
recordCompatibilityEvent(this.instanceName, "beforeUnmount");
|
||||||
|
},
|
||||||
|
unmounted() {
|
||||||
|
recordCompatibilityEvent(this.instanceName, "unmounted");
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
routePath() {
|
||||||
|
return (this as unknown as { $route: RouteLocationNormalizedLoaded })
|
||||||
|
.$route.fullPath;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article class="compat-probe" data-testid="options-lifecycle-probe">
|
||||||
|
<span class="compat-probe__badge">Options API</span>
|
||||||
|
<strong>{{ instanceName }}</strong>
|
||||||
|
<p><code>$route</code>: {{ routePath() }}</p>
|
||||||
|
<p>Revision {{ revision }} · local count {{ localCount }}</p>
|
||||||
|
<button type="button" @click="localCount += 1">
|
||||||
|
Increment local state
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
@@ -1,18 +1,83 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||||
import { useRegisterSW } from 'virtual:pwa-register/vue'
|
import { useRegisterSW } from 'virtual:pwa-register/vue'
|
||||||
import { useNativeRouter } from '@native-vue-router/core'
|
import { useNativeRouter } from '@native-vue-router/core'
|
||||||
|
import { recordPwaUpdateState } from '../pwa'
|
||||||
|
|
||||||
const native = useNativeRouter()
|
const native = useNativeRouter()
|
||||||
const { needRefresh, updateServiceWorker } = useRegisterSW()
|
const reloadPending = ref(false)
|
||||||
|
let registration: ServiceWorkerRegistration | undefined
|
||||||
|
let checkTimer: number | undefined
|
||||||
|
let reloading = false
|
||||||
|
let checking = false
|
||||||
|
|
||||||
|
function reloadWhenIdle() {
|
||||||
|
if (!reloadPending.value || native.transaction.value || reloading) return
|
||||||
|
reloading = true
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkForUpdate() {
|
||||||
|
if (!registration || checking || document.visibilityState === 'hidden' || !navigator.onLine) return
|
||||||
|
checking = true
|
||||||
|
recordPwaUpdateState('checking', true)
|
||||||
|
try {
|
||||||
|
await registration.update()
|
||||||
|
if (!reloadPending.value) recordPwaUpdateState('current')
|
||||||
|
} catch {
|
||||||
|
recordPwaUpdateState('error')
|
||||||
|
} finally {
|
||||||
|
checking = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { needRefresh, updateServiceWorker } = useRegisterSW({
|
||||||
|
immediate: true,
|
||||||
|
onRegisteredSW(_workerUrl, workerRegistration) {
|
||||||
|
registration = workerRegistration
|
||||||
|
void checkForUpdate()
|
||||||
|
if (checkTimer !== undefined) window.clearInterval(checkTimer)
|
||||||
|
checkTimer = window.setInterval(() => void checkForUpdate(), 60_000)
|
||||||
|
},
|
||||||
|
onNeedRefresh() {
|
||||||
|
recordPwaUpdateState('ready')
|
||||||
|
},
|
||||||
|
onNeedReload() {
|
||||||
|
recordPwaUpdateState('ready')
|
||||||
|
reloadPending.value = true
|
||||||
|
reloadWhenIdle()
|
||||||
|
},
|
||||||
|
onRegisterError() {
|
||||||
|
recordPwaUpdateState('error')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function checkWhenActive() {
|
||||||
|
if (document.visibilityState === 'visible') void checkForUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('focus', checkWhenActive)
|
||||||
|
window.addEventListener('online', checkWhenActive)
|
||||||
|
document.addEventListener('visibilitychange', checkWhenActive)
|
||||||
|
watch(() => native.transaction.value, reloadWhenIdle, { flush: 'post' })
|
||||||
|
|
||||||
function update() {
|
function update() {
|
||||||
|
reloadPending.value = true
|
||||||
if (!native.transaction.value) void updateServiceWorker(true)
|
if (!native.transaction.value) void updateServiceWorker(true)
|
||||||
|
reloadWhenIdle()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (checkTimer !== undefined) window.clearInterval(checkTimer)
|
||||||
|
window.removeEventListener('focus', checkWhenActive)
|
||||||
|
window.removeEventListener('online', checkWhenActive)
|
||||||
|
document.removeEventListener('visibilitychange', checkWhenActive)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<aside v-if="needRefresh" class="update-toast" role="status">
|
<aside v-if="needRefresh || reloadPending" class="update-toast" role="status">
|
||||||
<span>A fresh build is ready.</span>
|
<span>{{ native.transaction.value ? 'A fresh build will open after this gesture.' : 'A fresh build is ready.' }}</span>
|
||||||
<button type="button" :disabled="Boolean(native.transaction.value)" @click="update">Update</button>
|
<button v-if="needRefresh" type="button" :disabled="Boolean(native.transaction.value)" @click="update">Update</button>
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,103 +1,207 @@
|
|||||||
import { computed, reactive, readonly, ref } from 'vue'
|
import { computed, reactive, readonly, ref } from "vue";
|
||||||
|
|
||||||
export interface Person {
|
export interface Person {
|
||||||
id: string
|
id: string;
|
||||||
name: string
|
name: string;
|
||||||
handle: string
|
handle: string;
|
||||||
color: string
|
color: string;
|
||||||
online: boolean
|
online: boolean;
|
||||||
bio: string
|
bio: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
id: string
|
id: string;
|
||||||
personId: string
|
personId: string;
|
||||||
body: string
|
body: string;
|
||||||
sentAt: number
|
sentAt: number;
|
||||||
mine: boolean
|
mine: boolean;
|
||||||
status: 'sent' | 'delivered' | 'failed'
|
status: "sent" | "delivered" | "failed";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Conversation {
|
export interface Conversation {
|
||||||
id: string
|
id: string;
|
||||||
personId: string
|
personId: string;
|
||||||
unread: number
|
unread: number;
|
||||||
pinned?: boolean
|
pinned?: boolean;
|
||||||
messages: Message[]
|
messages: Message[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const people: Person[] = [
|
const people: Person[] = [
|
||||||
{ id: 'maya', name: 'Maya Chen', handle: '@mayac', color: '#ff7a8a', online: true, bio: 'Product designer · Sydney to everywhere.' },
|
{
|
||||||
{ id: 'noah', name: 'Noah Williams', handle: '@noahw', color: '#4f9cff', online: true, bio: 'Film, tiny cameras, and very long walks.' },
|
id: "maya",
|
||||||
{ id: 'sofia', name: 'Sofia Rossi', handle: '@sofiar', color: '#9d67ff', online: false, bio: 'Making typefaces and better pasta.' },
|
name: "Maya Chen",
|
||||||
{ id: 'liam', name: 'Liam Park', handle: '@liamp', color: '#35c7a0', online: true, bio: 'Engineer. Climber. Questionable DJ.' },
|
handle: "@mayac",
|
||||||
{ id: 'amara', name: 'Amara Okafor', handle: '@amarao', color: '#f4ad42', online: false, bio: 'Architecture and cities after dark.' },
|
color: "#ff7a8a",
|
||||||
]
|
online: true,
|
||||||
|
bio: "Product designer · Sydney to everywhere.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "noah",
|
||||||
|
name: "Noah Williams",
|
||||||
|
handle: "@noahw",
|
||||||
|
color: "#4f9cff",
|
||||||
|
online: true,
|
||||||
|
bio: "Film, tiny cameras, and very long walks.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sofia",
|
||||||
|
name: "Sofia Rossi",
|
||||||
|
handle: "@sofiar",
|
||||||
|
color: "#9d67ff",
|
||||||
|
online: false,
|
||||||
|
bio: "Making typefaces and better pasta.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "liam",
|
||||||
|
name: "Liam Park",
|
||||||
|
handle: "@liamp",
|
||||||
|
color: "#35c7a0",
|
||||||
|
online: true,
|
||||||
|
bio: "Engineer. Climber. Questionable DJ.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "amara",
|
||||||
|
name: "Amara Okafor",
|
||||||
|
handle: "@amarao",
|
||||||
|
color: "#f4ad42",
|
||||||
|
online: false,
|
||||||
|
bio: "Architecture and cities after dark.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const timestamp = Date.now()
|
const timestamp = Date.now();
|
||||||
const seedConversations: Conversation[] = [
|
const seedConversations: Conversation[] = [
|
||||||
{
|
{
|
||||||
id: 'maya', personId: 'maya', unread: 2, pinned: true,
|
id: "maya",
|
||||||
|
personId: "maya",
|
||||||
|
unread: 2,
|
||||||
|
pinned: true,
|
||||||
messages: [
|
messages: [
|
||||||
{ id: 'm1', personId: 'maya', body: 'That transition feels ridiculously smooth ✨', sentAt: timestamp - 840_000, mine: false, status: 'delivered' },
|
{
|
||||||
{ id: 'm2', personId: 'maya', body: 'Try holding it halfway, then let go slowly.', sentAt: timestamp - 780_000, mine: false, status: 'delivered' },
|
id: "m1",
|
||||||
|
personId: "maya",
|
||||||
|
body: "That transition feels ridiculously smooth ✨",
|
||||||
|
sentAt: timestamp - 840_000,
|
||||||
|
mine: false,
|
||||||
|
status: "delivered",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "m2",
|
||||||
|
personId: "maya",
|
||||||
|
body: "Try holding it halfway, then let go slowly.",
|
||||||
|
sentAt: timestamp - 780_000,
|
||||||
|
mine: false,
|
||||||
|
status: "delivered",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'noah', personId: 'noah', unread: 0,
|
id: "noah",
|
||||||
|
personId: "noah",
|
||||||
|
unread: 0,
|
||||||
messages: [
|
messages: [
|
||||||
{ id: 'n1', personId: 'noah', body: 'Uploaded the photos from yesterday.', sentAt: timestamp - 4_200_000, mine: false, status: 'delivered' },
|
{
|
||||||
{ id: 'n2', personId: 'noah', body: 'The grain is perfect.', sentAt: timestamp - 4_000_000, mine: true, status: 'delivered' },
|
id: "n1",
|
||||||
|
personId: "noah",
|
||||||
|
body: "Uploaded the photos from yesterday.",
|
||||||
|
sentAt: timestamp - 4_200_000,
|
||||||
|
mine: false,
|
||||||
|
status: "delivered",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "n2",
|
||||||
|
personId: "noah",
|
||||||
|
body: "The grain is perfect.",
|
||||||
|
sentAt: timestamp - 4_000_000,
|
||||||
|
mine: true,
|
||||||
|
status: "delivered",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'sofia', personId: 'sofia', unread: 1,
|
id: "sofia",
|
||||||
messages: [{ id: 's1', personId: 'sofia', body: 'Coffee at the new place tomorrow?', sentAt: timestamp - 18_000_000, mine: false, status: 'delivered' }],
|
personId: "sofia",
|
||||||
|
unread: 1,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: "s1",
|
||||||
|
personId: "sofia",
|
||||||
|
body: "Coffee at the new place tomorrow?",
|
||||||
|
sentAt: timestamp - 18_000_000,
|
||||||
|
mine: false,
|
||||||
|
status: "delivered",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'liam', personId: 'liam', unread: 0,
|
id: "liam",
|
||||||
messages: [{ id: 'l1', personId: 'liam', body: 'The build is green. Ship it.', sentAt: timestamp - 86_000_000, mine: false, status: 'delivered' }],
|
personId: "liam",
|
||||||
|
unread: 0,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: "l1",
|
||||||
|
personId: "liam",
|
||||||
|
body: "The build is green. Ship it.",
|
||||||
|
sentAt: timestamp - 86_000_000,
|
||||||
|
mine: false,
|
||||||
|
status: "delivered",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'amara', personId: 'amara', unread: 0,
|
id: "amara",
|
||||||
messages: [{ id: 'a1', personId: 'amara', body: 'This city never really goes quiet.', sentAt: timestamp - 172_000_000, mine: false, status: 'delivered' }],
|
personId: "amara",
|
||||||
|
unread: 0,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: "a1",
|
||||||
|
personId: "amara",
|
||||||
|
body: "This city never really goes quiet.",
|
||||||
|
sentAt: timestamp - 172_000_000,
|
||||||
|
mine: false,
|
||||||
|
status: "delivered",
|
||||||
},
|
},
|
||||||
]
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const conversations = ref<Conversation[]>(structuredClone(seedConversations))
|
const conversations = ref<Conversation[]>(structuredClone(seedConversations));
|
||||||
const ready = ref(false)
|
const ready = ref(false);
|
||||||
const offline = ref(!navigator.onLine)
|
const offline = ref(!navigator.onLine);
|
||||||
const settings = reactive({ simulatedLatency: 180, simulateFailures: false })
|
const settings = reactive({ simulatedLatency: 180, simulateFailures: false });
|
||||||
|
|
||||||
function openDatabase() {
|
function openDatabase() {
|
||||||
return new Promise<IDBDatabase>((resolve, reject) => {
|
return new Promise<IDBDatabase>((resolve, reject) => {
|
||||||
const request = indexedDB.open('native-vue-messenger', 1)
|
const request = indexedDB.open("native-vue-messenger", 1);
|
||||||
request.onupgradeneeded = () => request.result.createObjectStore('state')
|
request.onupgradeneeded = () => request.result.createObjectStore("state");
|
||||||
request.onsuccess = () => resolve(request.result)
|
request.onsuccess = () => resolve(request.result);
|
||||||
request.onerror = () => reject(request.error)
|
request.onerror = () => reject(request.error);
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readStored() {
|
async function readStored() {
|
||||||
const database = await openDatabase()
|
const database = await openDatabase();
|
||||||
return await new Promise<Conversation[] | undefined>((resolve, reject) => {
|
return await new Promise<Conversation[] | undefined>((resolve, reject) => {
|
||||||
const transaction = database.transaction('state', 'readonly')
|
const transaction = database.transaction("state", "readonly");
|
||||||
const request = transaction.objectStore('state').get('conversations')
|
const request = transaction.objectStore("state").get("conversations");
|
||||||
request.onsuccess = () => resolve(request.result as Conversation[] | undefined)
|
request.onsuccess = () =>
|
||||||
request.onerror = () => reject(request.error)
|
resolve(request.result as Conversation[] | undefined);
|
||||||
}).finally(() => database.close())
|
request.onerror = () => reject(request.error);
|
||||||
|
}).finally(() => database.close());
|
||||||
}
|
}
|
||||||
|
|
||||||
async function persist() {
|
async function persist() {
|
||||||
try {
|
try {
|
||||||
const database = await openDatabase()
|
const database = await openDatabase();
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const transaction = database.transaction('state', 'readwrite')
|
const transaction = database.transaction("state", "readwrite");
|
||||||
transaction.objectStore('state').put(structuredClone(conversations.value), 'conversations')
|
transaction
|
||||||
transaction.oncomplete = () => resolve()
|
.objectStore("state")
|
||||||
transaction.onerror = () => reject(transaction.error)
|
.put(structuredClone(conversations.value), "conversations");
|
||||||
})
|
transaction.oncomplete = () => resolve();
|
||||||
database.close()
|
transaction.onerror = () => reject(transaction.error);
|
||||||
|
});
|
||||||
|
database.close();
|
||||||
} catch {
|
} catch {
|
||||||
// Private browsing and locked-down webviews can reject IndexedDB.
|
// Private browsing and locked-down webviews can reject IndexedDB.
|
||||||
}
|
}
|
||||||
@@ -105,48 +209,58 @@ async function persist() {
|
|||||||
|
|
||||||
async function initialize() {
|
async function initialize() {
|
||||||
try {
|
try {
|
||||||
const stored = await readStored()
|
const stored = await readStored();
|
||||||
if (stored?.length) conversations.value = stored
|
if (stored?.length) conversations.value = stored;
|
||||||
else await persist()
|
else await persist();
|
||||||
} finally {
|
} finally {
|
||||||
ready.value = true
|
ready.value = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('online', () => { offline.value = false })
|
window.addEventListener("online", () => {
|
||||||
window.addEventListener('offline', () => { offline.value = true })
|
offline.value = false;
|
||||||
void initialize()
|
});
|
||||||
|
window.addEventListener("offline", () => {
|
||||||
|
offline.value = true;
|
||||||
|
});
|
||||||
|
void initialize();
|
||||||
|
|
||||||
export function useDemoStore() {
|
export function useDemoStore() {
|
||||||
const conversationFor = (id: string) => computed(() => conversations.value.find((conversation) => conversation.id === id))
|
const conversationFor = (id: string) =>
|
||||||
const personFor = (id: string) => people.find((person) => person.id === id)
|
computed(() =>
|
||||||
|
conversations.value.find((conversation) => conversation.id === id),
|
||||||
|
);
|
||||||
|
const personFor = (id: string) => people.find((person) => person.id === id);
|
||||||
const markRead = (id: string) => {
|
const markRead = (id: string) => {
|
||||||
const conversation = conversations.value.find((item) => item.id === id)
|
const conversation = conversations.value.find((item) => item.id === id);
|
||||||
if (conversation) conversation.unread = 0
|
if (conversation) conversation.unread = 0;
|
||||||
void persist()
|
void persist();
|
||||||
}
|
};
|
||||||
const sendMessage = async (id: string, body: string) => {
|
const sendMessage = async (id: string, body: string) => {
|
||||||
const conversation = conversations.value.find((item) => item.id === id)
|
const conversation = conversations.value.find((item) => item.id === id);
|
||||||
if (!conversation || !body.trim()) return false
|
if (!conversation || !body.trim()) return false;
|
||||||
const message: Message = {
|
const message: Message = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
personId: id,
|
personId: id,
|
||||||
body: body.trim(),
|
body: body.trim(),
|
||||||
sentAt: Date.now(),
|
sentAt: Date.now(),
|
||||||
mine: true,
|
mine: true,
|
||||||
status: 'sent',
|
status: "sent",
|
||||||
}
|
};
|
||||||
conversation.messages.push(message)
|
conversation.messages.push(message);
|
||||||
await persist()
|
await persist();
|
||||||
await new Promise((resolve) => window.setTimeout(resolve, settings.simulatedLatency))
|
await new Promise((resolve) =>
|
||||||
message.status = settings.simulateFailures || offline.value ? 'failed' : 'delivered'
|
window.setTimeout(resolve, settings.simulatedLatency),
|
||||||
await persist()
|
);
|
||||||
return message.status === 'delivered'
|
message.status =
|
||||||
}
|
settings.simulateFailures || offline.value ? "failed" : "delivered";
|
||||||
|
await persist();
|
||||||
|
return message.status === "delivered";
|
||||||
|
};
|
||||||
const reset = async () => {
|
const reset = async () => {
|
||||||
conversations.value = structuredClone(seedConversations)
|
conversations.value = structuredClone(seedConversations);
|
||||||
await persist()
|
await persist();
|
||||||
}
|
};
|
||||||
return {
|
return {
|
||||||
people: readonly(ref(people)),
|
people: readonly(ref(people)),
|
||||||
conversations: readonly(conversations),
|
conversations: readonly(conversations),
|
||||||
@@ -158,14 +272,14 @@ export function useDemoStore() {
|
|||||||
markRead,
|
markRead,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
reset,
|
reset,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function relativeTime(value: number) {
|
export function relativeTime(value: number) {
|
||||||
const minutes = Math.floor((Date.now() - value) / 60_000)
|
const minutes = Math.floor((Date.now() - value) / 60_000);
|
||||||
if (minutes < 1) return 'now'
|
if (minutes < 1) return "now";
|
||||||
if (minutes < 60) return `${minutes}m`
|
if (minutes < 60) return `${minutes}m`;
|
||||||
const hours = Math.floor(minutes / 60)
|
const hours = Math.floor(minutes / 60);
|
||||||
if (hours < 24) return `${hours}h`
|
if (hours < 24) return `${hours}h`;
|
||||||
return `${Math.floor(hours / 24)}d`
|
return `${Math.floor(hours / 24)}d`;
|
||||||
}
|
}
|
||||||
|
|||||||
50
apps/demo/src/guard-state.ts
Normal file
50
apps/demo/src/guard-state.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { readonly, reactive } from "vue";
|
||||||
|
|
||||||
|
function createGuardState() {
|
||||||
|
return reactive({
|
||||||
|
blockEntry: false,
|
||||||
|
checks: 0,
|
||||||
|
status: "idle" as "idle" | "checking" | "allowed" | "blocked",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const storyState = createGuardState();
|
||||||
|
const labState = createGuardState();
|
||||||
|
|
||||||
|
export const storyEntryGuard = readonly(storyState);
|
||||||
|
export const runtimeLabGuard = readonly(labState);
|
||||||
|
|
||||||
|
export function setStoryEntryBlocked(blocked: boolean) {
|
||||||
|
storyState.blockEntry = blocked;
|
||||||
|
if (storyState.status !== "checking") storyState.status = "idle";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function evaluate(state: ReturnType<typeof createGuardState>) {
|
||||||
|
state.checks += 1;
|
||||||
|
state.status = "checking";
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, 320));
|
||||||
|
const allowed = !state.blockEntry;
|
||||||
|
state.status = allowed ? "allowed" : "blocked";
|
||||||
|
return allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dynamic guard used to reject a sibling that may already be cached. */
|
||||||
|
export function evaluateStoryEntry() {
|
||||||
|
storyState.checks += 1;
|
||||||
|
if (!storyState.blockEntry) {
|
||||||
|
storyState.status = "allowed";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
storyState.status = "checking";
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
storyState.status = "blocked";
|
||||||
|
resolve(false);
|
||||||
|
}, 320);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Always-allowing asynchronous guard for the deeper stress-lab route. */
|
||||||
|
export function evaluateRuntimeLabEntry() {
|
||||||
|
return evaluate(labState);
|
||||||
|
}
|
||||||
@@ -1,29 +1,34 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from "vue";
|
||||||
import { createNativeRouter } from '@native-vue-router/core'
|
import { createNativeRouter } from "@native-vue-router/core";
|
||||||
import { createCapacitorAdapter } from '@native-vue-router/capacitor'
|
import { createCapacitorAdapter } from "@native-vue-router/capacitor";
|
||||||
import { createElectronRendererAdapter } from '@native-vue-router/electron'
|
import { createElectronRendererAdapter } from "@native-vue-router/electron";
|
||||||
import App from './App.vue'
|
import App from "./App.vue";
|
||||||
import { createPwaAdapter } from './pwa'
|
import { demoAppValueKey } from "./compatibility-lab";
|
||||||
import { router } from './router'
|
import { createPwaAdapter } from "./pwa";
|
||||||
import './style.css'
|
import { router } from "./router";
|
||||||
|
import "./style.css";
|
||||||
|
|
||||||
const isElectron = navigator.userAgent.toLowerCase().includes('electron')
|
const isElectron = navigator.userAgent.toLowerCase().includes("electron");
|
||||||
const capacitorPlatform = createCapacitorAdapter({ haptics: true, exitAtRoot: true })
|
const capacitorPlatform = createCapacitorAdapter({
|
||||||
|
haptics: true,
|
||||||
|
exitAtRoot: true,
|
||||||
|
});
|
||||||
const platform = isElectron
|
const platform = isElectron
|
||||||
? createElectronRendererAdapter()
|
? createElectronRendererAdapter()
|
||||||
: capacitorPlatform.name !== 'capacitor-web'
|
: capacitorPlatform.name !== "capacitor-web"
|
||||||
? capacitorPlatform
|
? capacitorPlatform
|
||||||
: createPwaAdapter()
|
: createPwaAdapter();
|
||||||
|
|
||||||
const nativeRouter = createNativeRouter({
|
const nativeRouter = createNativeRouter({
|
||||||
router,
|
router,
|
||||||
cache: { maxInactive: 8 },
|
cache: { maxInactive: 4 },
|
||||||
platform,
|
platform,
|
||||||
})
|
});
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App);
|
||||||
app.use(router)
|
app.use(router);
|
||||||
app.use(nativeRouter)
|
app.use(nativeRouter);
|
||||||
|
app.provide(demoAppValueKey, "Injected from the demo application root");
|
||||||
|
|
||||||
await router.isReady()
|
await router.isReady();
|
||||||
app.mount('#app')
|
app.mount("#app");
|
||||||
|
|||||||
74
apps/demo/src/navigation-profiler.ts
Normal file
74
apps/demo/src/navigation-profiler.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { ref } from "vue";
|
||||||
|
import {
|
||||||
|
createNativeNavigationProfiler,
|
||||||
|
type NativeNavigationProfiler,
|
||||||
|
type NativeProfilerReport,
|
||||||
|
type NativeRouterRuntime,
|
||||||
|
} from "@native-vue-router/core";
|
||||||
|
import { pwaBuildId } from "./pwa";
|
||||||
|
|
||||||
|
let profiler: NativeNavigationProfiler | undefined;
|
||||||
|
export const profilerRecording = ref(false);
|
||||||
|
export const profilerHasCapture = ref(false);
|
||||||
|
|
||||||
|
function instance(runtime: NativeRouterRuntime) {
|
||||||
|
profiler ??= createNativeNavigationProfiler(runtime, {
|
||||||
|
metadata: { app: "nvr-messenger-demo", build: pwaBuildId },
|
||||||
|
});
|
||||||
|
return profiler;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startDemoProfile(runtime: NativeRouterRuntime) {
|
||||||
|
const active = instance(runtime);
|
||||||
|
active.start();
|
||||||
|
profilerRecording.value = true;
|
||||||
|
profilerHasCapture.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopDemoProfile(runtime: NativeRouterRuntime) {
|
||||||
|
const report = instance(runtime).stop();
|
||||||
|
profilerRecording.value = false;
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function snapshotDemoProfile(runtime: NativeRouterRuntime) {
|
||||||
|
return instance(runtime).snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function shareDemoProfile(
|
||||||
|
runtime: NativeRouterRuntime,
|
||||||
|
report?: NativeProfilerReport,
|
||||||
|
) {
|
||||||
|
const active = instance(runtime);
|
||||||
|
const current =
|
||||||
|
report ??
|
||||||
|
(profilerRecording.value ? stopDemoProfile(runtime) : active.snapshot());
|
||||||
|
const json = active.toJSON(current);
|
||||||
|
const stamp = new Date()
|
||||||
|
.toISOString()
|
||||||
|
.replaceAll(":", "-")
|
||||||
|
.replaceAll(".", "-");
|
||||||
|
const filename = `native-vue-router-profile-${stamp}.json`;
|
||||||
|
const file = new File([json], filename, { type: "application/json" });
|
||||||
|
const shareNavigator = navigator as Navigator & {
|
||||||
|
canShare?: (data: ShareData) => boolean;
|
||||||
|
share?: (data: ShareData) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (shareNavigator.share && shareNavigator.canShare?.({ files: [file] })) {
|
||||||
|
await shareNavigator.share({
|
||||||
|
title: "Native Vue Router performance profile",
|
||||||
|
text: "Frame pacing and navigation diagnostics. Route params and query values are omitted.",
|
||||||
|
files: [file],
|
||||||
|
});
|
||||||
|
return { report: current, method: "shared" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
link.click();
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(url), 1_000);
|
||||||
|
return { report: current, method: "downloaded" as const };
|
||||||
|
}
|
||||||
@@ -1,45 +1,77 @@
|
|||||||
import { reactive, readonly } from 'vue'
|
import { reactive, readonly } from "vue";
|
||||||
import type { NativePlatformAdapter } from '@native-vue-router/core'
|
import type { NativePlatformAdapter } from "@native-vue-router/core";
|
||||||
|
|
||||||
interface StandaloneNavigator extends Navigator {
|
interface StandaloneNavigator extends Navigator {
|
||||||
standalone?: boolean
|
standalone?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServiceWorkerState = 'unsupported' | 'installing' | 'ready'
|
type ServiceWorkerState = "unsupported" | "installing" | "ready";
|
||||||
|
export type PwaUpdateState =
|
||||||
|
"idle" | "checking" | "current" | "ready" | "error";
|
||||||
|
|
||||||
|
declare const __NVR_BUILD_ID__: string;
|
||||||
|
|
||||||
|
export const pwaBuildId = __NVR_BUILD_ID__;
|
||||||
|
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
ios: false,
|
ios: false,
|
||||||
standalone: false,
|
standalone: false,
|
||||||
edgeGuard: false,
|
edgeGuard: false,
|
||||||
edgeClaims: 0,
|
edgeClaims: 0,
|
||||||
serviceWorker: 'installing' as ServiceWorkerState,
|
serviceWorker: "installing" as ServiceWorkerState,
|
||||||
})
|
updateState: "idle" as PwaUpdateState,
|
||||||
|
updateChecks: 0,
|
||||||
|
});
|
||||||
|
|
||||||
export const pwaEnvironment = readonly(state)
|
export const pwaEnvironment = readonly(state);
|
||||||
|
|
||||||
|
export function recordPwaUpdateState(
|
||||||
|
updateState: PwaUpdateState,
|
||||||
|
checked = false,
|
||||||
|
) {
|
||||||
|
state.updateState = updateState;
|
||||||
|
if (checked) state.updateChecks += 1;
|
||||||
|
document.documentElement.dataset.pwaUpdateState = state.updateState;
|
||||||
|
document.documentElement.dataset.pwaUpdateChecks = String(state.updateChecks);
|
||||||
|
}
|
||||||
|
|
||||||
export function isIOSWebKit() {
|
export function isIOSWebKit() {
|
||||||
const navigatorWithTouch = navigator as Navigator & { maxTouchPoints?: number }
|
const navigatorWithTouch = navigator as Navigator & {
|
||||||
return /iPad|iPhone|iPod/.test(navigator.userAgent)
|
maxTouchPoints?: number;
|
||||||
|| (/Macintosh/.test(navigator.userAgent) && (navigatorWithTouch.maxTouchPoints ?? 0) > 1)
|
};
|
||||||
|
return (
|
||||||
|
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
||||||
|
(/Macintosh/.test(navigator.userAgent) &&
|
||||||
|
(navigatorWithTouch.maxTouchPoints ?? 0) > 1)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isStandalonePwa() {
|
export function isStandalonePwa() {
|
||||||
return Boolean((navigator as StandaloneNavigator).standalone)
|
return (
|
||||||
|| window.matchMedia('(display-mode: standalone)').matches
|
Boolean((navigator as StandaloneNavigator).standalone) ||
|
||||||
|| window.matchMedia('(display-mode: fullscreen)').matches
|
window.matchMedia("(display-mode: standalone)").matches ||
|
||||||
|
window.matchMedia("(display-mode: fullscreen)").matches
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateEnvironment() {
|
function updateEnvironment() {
|
||||||
state.ios = isIOSWebKit()
|
state.ios = isIOSWebKit();
|
||||||
state.standalone = isStandalonePwa()
|
state.standalone = isStandalonePwa();
|
||||||
state.edgeGuard = state.ios && state.standalone
|
state.edgeGuard = state.ios && state.standalone;
|
||||||
document.documentElement.dataset.pwaPlatform = state.ios ? 'ios' : 'other'
|
document.documentElement.dataset.pwaPlatform = state.ios ? "ios" : "other";
|
||||||
document.documentElement.dataset.pwaDisplayMode = state.standalone ? 'standalone' : 'browser'
|
document.documentElement.dataset.pwaDisplayMode = state.standalone
|
||||||
document.documentElement.dataset.pwaEdgeGuard = state.edgeGuard ? 'active' : 'inactive'
|
? "standalone"
|
||||||
|
: "browser";
|
||||||
|
document.documentElement.dataset.pwaEdgeGuard = state.edgeGuard
|
||||||
|
? "active"
|
||||||
|
: "inactive";
|
||||||
|
document.documentElement.dataset.pwaEdgeClaims = String(state.edgeClaims);
|
||||||
|
document.documentElement.dataset.pwaUpdateState = state.updateState;
|
||||||
|
document.documentElement.dataset.pwaUpdateChecks = String(state.updateChecks);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PwaAdapterOptions {
|
export interface PwaAdapterOptions {
|
||||||
edgeWidth?: number
|
edgeWidth?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,64 +81,104 @@ export interface PwaAdapterOptions {
|
|||||||
* Safari does not expose WKWebView's native gesture switch to web content,
|
* Safari does not expose WKWebView's native gesture switch to web content,
|
||||||
* so this is deliberately scoped to standalone mode and reinforced by CSS.
|
* so this is deliberately scoped to standalone mode and reinforced by CSS.
|
||||||
*/
|
*/
|
||||||
export function createPwaAdapter(options: PwaAdapterOptions = {}): NativePlatformAdapter {
|
export function createPwaAdapter(
|
||||||
const edgeWidth = options.edgeWidth ?? 32
|
options: PwaAdapterOptions = {},
|
||||||
|
): NativePlatformAdapter {
|
||||||
|
const edgeWidth = options.edgeWidth ?? 32;
|
||||||
return {
|
return {
|
||||||
name: 'pwa',
|
name: "pwa",
|
||||||
install() {
|
install() {
|
||||||
updateEnvironment()
|
updateEnvironment();
|
||||||
const displayMode = window.matchMedia('(display-mode: standalone)')
|
const displayMode = window.matchMedia("(display-mode: standalone)");
|
||||||
const update = () => updateEnvironment()
|
const update = () => updateEnvironment();
|
||||||
displayMode.addEventListener('change', update)
|
displayMode.addEventListener("change", update);
|
||||||
document.addEventListener('visibilitychange', update)
|
document.addEventListener("visibilitychange", update);
|
||||||
|
|
||||||
if (!('serviceWorker' in navigator)) state.serviceWorker = 'unsupported'
|
if (!("serviceWorker" in navigator)) state.serviceWorker = "unsupported";
|
||||||
else {
|
else {
|
||||||
state.serviceWorker = navigator.serviceWorker.controller ? 'ready' : 'installing'
|
state.serviceWorker = navigator.serviceWorker.controller
|
||||||
void navigator.serviceWorker.ready.then(() => { state.serviceWorker = 'ready' })
|
? "ready"
|
||||||
navigator.serviceWorker.addEventListener('controllerchange', updateServiceWorkerState)
|
: "installing";
|
||||||
|
void navigator.serviceWorker.ready.then(() => {
|
||||||
|
state.serviceWorker = "ready";
|
||||||
|
});
|
||||||
|
navigator.serviceWorker.addEventListener(
|
||||||
|
"controllerchange",
|
||||||
|
updateServiceWorkerState,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let reservedTouch: number | null = null
|
let reservedTouch: number | null = null;
|
||||||
const touchAtLeadingEdge = (event: TouchEvent) => {
|
const touchAtLeadingEdge = (event: TouchEvent) => {
|
||||||
if (!state.edgeGuard || event.touches.length !== 1) return undefined
|
if (!state.edgeGuard || event.touches.length !== 1) return undefined;
|
||||||
if (!(event.target instanceof Element) || !event.target.closest('.nvr-navigator')) return undefined
|
if (
|
||||||
const touch = event.touches[0]
|
!(event.target instanceof Element) ||
|
||||||
const rtl = getComputedStyle(document.documentElement).direction === 'rtl'
|
!event.target.closest(".nvr-navigator")
|
||||||
const atEdge = rtl ? window.innerWidth - touch.clientX <= edgeWidth : touch.clientX <= edgeWidth
|
)
|
||||||
return atEdge ? touch : undefined
|
return undefined;
|
||||||
}
|
const touch = event.touches[0];
|
||||||
|
const rtl =
|
||||||
|
getComputedStyle(document.documentElement).direction === "rtl";
|
||||||
|
const atEdge = rtl
|
||||||
|
? window.innerWidth - touch.clientX <= edgeWidth
|
||||||
|
: touch.clientX <= edgeWidth;
|
||||||
|
return atEdge ? touch : undefined;
|
||||||
|
};
|
||||||
const reserveEdge = (event: TouchEvent) => {
|
const reserveEdge = (event: TouchEvent) => {
|
||||||
const touch = touchAtLeadingEdge(event)
|
const touch = touchAtLeadingEdge(event);
|
||||||
if (!touch) return
|
if (!touch) return;
|
||||||
reservedTouch = touch.identifier
|
reservedTouch = touch.identifier;
|
||||||
state.edgeClaims += 1
|
state.edgeClaims += 1;
|
||||||
event.preventDefault()
|
document.documentElement.dataset.pwaEdgeClaims = String(
|
||||||
}
|
state.edgeClaims,
|
||||||
|
);
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
const holdEdge = (event: TouchEvent) => {
|
const holdEdge = (event: TouchEvent) => {
|
||||||
if (reservedTouch === null) return
|
if (reservedTouch === null) return;
|
||||||
if ([...event.touches].some((touch) => touch.identifier === reservedTouch)) event.preventDefault()
|
if (
|
||||||
}
|
[...event.touches].some((touch) => touch.identifier === reservedTouch)
|
||||||
const releaseEdge = () => { reservedTouch = null }
|
)
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
const releaseEdge = () => {
|
||||||
|
reservedTouch = null;
|
||||||
|
};
|
||||||
|
|
||||||
document.addEventListener('touchstart', reserveEdge, { capture: true, passive: false })
|
document.addEventListener("touchstart", reserveEdge, {
|
||||||
document.addEventListener('touchmove', holdEdge, { capture: true, passive: false })
|
capture: true,
|
||||||
document.addEventListener('touchend', releaseEdge, { capture: true, passive: true })
|
passive: false,
|
||||||
document.addEventListener('touchcancel', releaseEdge, { capture: true, passive: true })
|
});
|
||||||
|
document.addEventListener("touchmove", holdEdge, {
|
||||||
|
capture: true,
|
||||||
|
passive: false,
|
||||||
|
});
|
||||||
|
document.addEventListener("touchend", releaseEdge, {
|
||||||
|
capture: true,
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
document.addEventListener("touchcancel", releaseEdge, {
|
||||||
|
capture: true,
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
displayMode.removeEventListener('change', update)
|
displayMode.removeEventListener("change", update);
|
||||||
document.removeEventListener('visibilitychange', update)
|
document.removeEventListener("visibilitychange", update);
|
||||||
if ('serviceWorker' in navigator) navigator.serviceWorker.removeEventListener('controllerchange', updateServiceWorkerState)
|
if ("serviceWorker" in navigator)
|
||||||
document.removeEventListener('touchstart', reserveEdge, true)
|
navigator.serviceWorker.removeEventListener(
|
||||||
document.removeEventListener('touchmove', holdEdge, true)
|
"controllerchange",
|
||||||
document.removeEventListener('touchend', releaseEdge, true)
|
updateServiceWorkerState,
|
||||||
document.removeEventListener('touchcancel', releaseEdge, true)
|
);
|
||||||
}
|
document.removeEventListener("touchstart", reserveEdge, true);
|
||||||
|
document.removeEventListener("touchmove", holdEdge, true);
|
||||||
|
document.removeEventListener("touchend", releaseEdge, true);
|
||||||
|
document.removeEventListener("touchcancel", releaseEdge, true);
|
||||||
|
};
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateServiceWorkerState() {
|
function updateServiceWorkerState() {
|
||||||
state.serviceWorker = 'ready'
|
state.serviceWorker = "ready";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,141 @@
|
|||||||
import { createRouter, createWebHashHistory, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
import {
|
||||||
|
createRouter,
|
||||||
|
createWebHashHistory,
|
||||||
|
createWebHistory,
|
||||||
|
type RouteRecordRaw,
|
||||||
|
} from "vue-router";
|
||||||
|
import { evaluateRuntimeLabEntry, evaluateStoryEntry } from "./guard-state";
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{ path: '/', redirect: '/inbox' },
|
{ path: "/", redirect: "/inbox" },
|
||||||
{
|
{
|
||||||
path: '/inbox', name: 'inbox', component: () => import('./views/InboxView.vue'),
|
path: "/inbox",
|
||||||
meta: { tab: true, native: { siblingGroup: 'primary', siblingOrder: 0, siblingHistory: 'replace', gesture: 'full' } },
|
name: "inbox",
|
||||||
|
component: () => import("./views/InboxView.vue"),
|
||||||
|
meta: {
|
||||||
|
tab: true,
|
||||||
|
native: {
|
||||||
|
siblingGroup: "primary",
|
||||||
|
siblingOrder: 0,
|
||||||
|
siblingHistory: "replace",
|
||||||
|
gesture: "full",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/stories', name: 'stories', component: () => import('./views/StoriesView.vue'),
|
path: "/stories",
|
||||||
meta: { tab: true, native: { siblingGroup: 'primary', siblingOrder: 1, siblingHistory: 'replace', gesture: 'full' } },
|
name: "stories",
|
||||||
|
component: () => import("./views/StoriesView.vue"),
|
||||||
|
beforeEnter: evaluateStoryEntry,
|
||||||
|
meta: {
|
||||||
|
tab: true,
|
||||||
|
native: {
|
||||||
|
siblingGroup: "primary",
|
||||||
|
siblingOrder: 1,
|
||||||
|
siblingHistory: "replace",
|
||||||
|
gesture: "full",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/profile', name: 'profile', component: () => import('./views/ProfileView.vue'),
|
path: "/profile",
|
||||||
meta: { tab: true, native: { siblingGroup: 'primary', siblingOrder: 2, siblingHistory: 'replace', gesture: 'full' } },
|
name: "profile",
|
||||||
|
component: () => import("./views/ProfileView.vue"),
|
||||||
|
meta: {
|
||||||
|
tab: true,
|
||||||
|
native: {
|
||||||
|
siblingGroup: "primary",
|
||||||
|
siblingOrder: 2,
|
||||||
|
siblingHistory: "replace",
|
||||||
|
gesture: "full",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/chat/:id', name: 'chat', component: () => import('./views/ChatView.vue'),
|
path: "/profile/runtime-lab",
|
||||||
meta: { native: { presentation: 'push', parent: '/inbox', gesture: 'edge' } },
|
name: "runtime-lab",
|
||||||
|
component: () => import("./views/RuntimeLabView.vue"),
|
||||||
|
beforeEnter: evaluateRuntimeLabEntry,
|
||||||
|
meta: {
|
||||||
|
tab: true,
|
||||||
|
native: {
|
||||||
|
siblingGroup: "primary",
|
||||||
|
siblingOrder: 3,
|
||||||
|
siblingHistory: "push",
|
||||||
|
presentation: "slide",
|
||||||
|
parent: "/profile",
|
||||||
|
gesture: "full",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/chat/:id/details', name: 'chat-details', component: () => import('./views/ContactView.vue'),
|
path: "/chat/:id",
|
||||||
meta: { native: { presentation: 'push', parent: (route) => `/chat/${String(route.params.id)}`, gesture: 'edge' } },
|
name: "chat",
|
||||||
|
component: () => import("./views/ChatView.vue"),
|
||||||
|
meta: {
|
||||||
|
native: { presentation: "push", parent: "/inbox", gesture: "edge" },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/compose', name: 'compose', component: () => import('./views/ComposeView.vue'),
|
path: "/chat/:id/details",
|
||||||
meta: { native: { presentation: 'sheet', parent: '/inbox', gesture: 'full' } },
|
name: "chat-details",
|
||||||
|
component: () => import("./views/ContactView.vue"),
|
||||||
|
meta: {
|
||||||
|
native: {
|
||||||
|
presentation: "push",
|
||||||
|
parent: (route) => `/chat/${String(route.params.id)}`,
|
||||||
|
gesture: "edge",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/settings', name: 'settings', component: () => import('./views/SettingsView.vue'),
|
path: "/compose",
|
||||||
meta: { native: { presentation: 'push', parent: '/profile', gesture: 'edge' } },
|
name: "compose",
|
||||||
|
component: () => import("./views/ComposeView.vue"),
|
||||||
|
meta: {
|
||||||
|
native: { presentation: "sheet", parent: "/inbox", gesture: "full" },
|
||||||
},
|
},
|
||||||
]
|
},
|
||||||
|
{
|
||||||
|
path: "/settings",
|
||||||
|
name: "settings",
|
||||||
|
component: () => import("./views/SettingsView.vue"),
|
||||||
|
meta: {
|
||||||
|
native: { presentation: "push", parent: "/profile", gesture: "edge" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/profile/vue-lab/:sample",
|
||||||
|
name: "vue-compatibility",
|
||||||
|
component: () => import("./views/VueCompatibilityView.vue"),
|
||||||
|
meta: {
|
||||||
|
native: { presentation: "push", parent: "/profile", gesture: "edge" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/profile/vue-lab/:sample/away",
|
||||||
|
name: "vue-compatibility-away",
|
||||||
|
component: () => import("./views/VueCompatibilityAwayView.vue"),
|
||||||
|
meta: {
|
||||||
|
native: {
|
||||||
|
presentation: "push",
|
||||||
|
parent: (route) => ({
|
||||||
|
name: "vue-compatibility",
|
||||||
|
params: { sample: route.params.sample },
|
||||||
|
query: route.query,
|
||||||
|
hash: route.hash,
|
||||||
|
}),
|
||||||
|
gesture: "edge",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export const router = createRouter({
|
export const router = createRouter({
|
||||||
history: window.location.protocol === 'file:' ? createWebHashHistory() : createWebHistory(),
|
history:
|
||||||
|
window.location.protocol === "file:"
|
||||||
|
? createWebHashHistory()
|
||||||
|
: createWebHistory(),
|
||||||
routes,
|
routes,
|
||||||
scrollBehavior: () => ({ top: 0 }),
|
scrollBehavior: () => ({ top: 0 }),
|
||||||
})
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,37 +1,66 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from "vue";
|
||||||
import { NativeDismissGesture, useNativeRouter } from '@native-vue-router/core'
|
import { NativeSheet, useNativeRouter } from "@native-vue-router/core";
|
||||||
import AppAvatar from '../components/AppAvatar.vue'
|
import AppAvatar from "../components/AppAvatar.vue";
|
||||||
import { useDemoStore } from '../data'
|
import { useDemoStore } from "../data";
|
||||||
|
|
||||||
const native = useNativeRouter()
|
const native = useNativeRouter();
|
||||||
const store = useDemoStore()
|
const store = useDemoStore();
|
||||||
const query = ref('')
|
const query = ref("");
|
||||||
|
const sheetBreakpoint = ref(0.62);
|
||||||
|
const snapEnabled = ref(true);
|
||||||
|
const sheetBreakpoints = computed(() =>
|
||||||
|
snapEnabled.value ? [0.38, 0.62, 1] : [],
|
||||||
|
);
|
||||||
|
|
||||||
async function choose(id: string) {
|
async function choose(id: string) {
|
||||||
await native.cancelInteractive()
|
await native.cancelInteractive();
|
||||||
await native.replace(`/chat/${id}`, { presentation: 'push' })
|
await native.replace(`/chat/${id}`, { presentation: "push" });
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<NativeDismissGesture as="main" class="screen sheet-screen">
|
<NativeSheet
|
||||||
<div class="sheet-handle" aria-hidden="true" />
|
v-model="sheetBreakpoint"
|
||||||
|
class="compose-sheet"
|
||||||
|
aria-label="New message"
|
||||||
|
:breakpoints="sheetBreakpoints"
|
||||||
|
:initial-breakpoint="0.62"
|
||||||
|
>
|
||||||
|
<main class="compose-sheet-content">
|
||||||
<header class="sheet-header">
|
<header class="sheet-header">
|
||||||
<button type="button" @click="native.dismiss()">Cancel</button>
|
<button type="button" @click="native.dismiss()">Cancel</button>
|
||||||
|
<div>
|
||||||
<h1>New message</h1>
|
<h1>New message</h1>
|
||||||
<span />
|
<small data-testid="sheet-size">
|
||||||
|
{{ snapEnabled ? `${Math.round(sheetBreakpoint * 100)}%` : "Auto" }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<button type="button" @click="snapEnabled = !snapEnabled">
|
||||||
|
{{ snapEnabled ? "Fit content" : "Use snap points" }}
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<label class="search-field compose-search">
|
<label class="search-field compose-search">
|
||||||
<span>To:</span>
|
<span>To:</span>
|
||||||
<input v-model="query" autofocus placeholder="Search people" />
|
<input v-model="query" autofocus placeholder="Search people" />
|
||||||
</label>
|
</label>
|
||||||
<div class="conversation-list">
|
<div class="conversation-list">
|
||||||
<button v-for="person in store.people.value.filter((item) => item.name.toLowerCase().includes(query.toLowerCase()))" :key="person.id" class="conversation-row" @click="choose(person.id)">
|
<button
|
||||||
|
v-for="person in store.people.value.filter((item) =>
|
||||||
|
item.name.toLowerCase().includes(query.toLowerCase()),
|
||||||
|
)"
|
||||||
|
:key="person.id"
|
||||||
|
class="conversation-row"
|
||||||
|
@click="choose(person.id)"
|
||||||
|
>
|
||||||
<AppAvatar :person="person" />
|
<AppAvatar :person="person" />
|
||||||
<div class="conversation-copy"><strong>{{ person.name }}</strong><p>{{ person.handle }}</p></div>
|
<div class="conversation-copy">
|
||||||
|
<strong>{{ person.name }}</strong>
|
||||||
|
<p>{{ person.handle }}</p>
|
||||||
|
</div>
|
||||||
<span class="chevron">›</span>
|
<span class="chevron">›</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</NativeDismissGesture>
|
</main>
|
||||||
|
</NativeSheet>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NativeLink } from '@native-vue-router/core'
|
import { NativeLink, useNativeRouter } from "@native-vue-router/core";
|
||||||
import AppHeader from '../components/AppHeader.vue'
|
import AppHeader from "../components/AppHeader.vue";
|
||||||
|
import { setStoryEntryBlocked, storyEntryGuard } from "../guard-state";
|
||||||
|
|
||||||
|
const native = useNativeRouter();
|
||||||
|
|
||||||
|
function toggleStoryGuard() {
|
||||||
|
setStoryEntryBlocked(!storyEntryGuard.blockEntry);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -17,9 +24,35 @@ import AppHeader from '../components/AppHeader.vue'
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="settings-list">
|
<section class="settings-list">
|
||||||
<NativeLink to="/settings"><span>⚙︎</span><strong>Navigation lab</strong><i>›</i></NativeLink>
|
<a
|
||||||
<a href="https://github.com" target="_blank" rel="noreferrer"><span>⌘</span><strong>Project source</strong><i>↗</i></a>
|
href="/profile/runtime-lab"
|
||||||
<button type="button"><span>◐</span><strong>Appearance</strong><i>System</i></button>
|
@click.prevent="native.sibling('/profile/runtime-lab')"
|
||||||
|
><span>⌁</span><strong>Runtime stress lab</strong><i>›</i></a
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Block Stories re-entry"
|
||||||
|
:aria-pressed="storyEntryGuard.blockEntry"
|
||||||
|
@click="toggleStoryGuard"
|
||||||
|
>
|
||||||
|
<span>⌽</span><strong>Block cached Stories re-entry</strong
|
||||||
|
><i data-testid="story-guard-status">{{
|
||||||
|
storyEntryGuard.blockEntry ? storyEntryGuard.status : "Off"
|
||||||
|
}}</i>
|
||||||
|
</button>
|
||||||
|
<NativeLink to="/settings"
|
||||||
|
><span>⚙︎</span><strong>Navigation lab</strong><i>›</i></NativeLink
|
||||||
|
>
|
||||||
|
<NativeLink to="/profile/vue-lab/alpha?mode=manual#route-state"
|
||||||
|
><span>Vue</span><strong>Vue compatibility lab</strong
|
||||||
|
><i>›</i></NativeLink
|
||||||
|
>
|
||||||
|
<a href="https://github.com" target="_blank" rel="noreferrer"
|
||||||
|
><span>⌘</span><strong>Project source</strong><i>↗</i></a
|
||||||
|
>
|
||||||
|
<button type="button">
|
||||||
|
<span>◐</span><strong>Appearance</strong><i>System</i>
|
||||||
|
</button>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
66
apps/demo/src/views/RuntimeLabView.vue
Normal file
66
apps/demo/src/views/RuntimeLabView.vue
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useNativeViewActiveEffect } from '@native-vue-router/core'
|
||||||
|
import AppHeader from '../components/AppHeader.vue'
|
||||||
|
import AsyncLabData from '../components/AsyncLabData.vue'
|
||||||
|
import { runtimeLabGuard } from '../guard-state'
|
||||||
|
|
||||||
|
const mountedSeconds = ref(0)
|
||||||
|
const mountedAt = Date.now()
|
||||||
|
const mountId = crypto.randomUUID()
|
||||||
|
|
||||||
|
useNativeViewActiveEffect(() => {
|
||||||
|
const update = () => {
|
||||||
|
mountedSeconds.value = Math.floor((Date.now() - mountedAt) / 1_000)
|
||||||
|
}
|
||||||
|
update()
|
||||||
|
const timer = window.setInterval(update, 200)
|
||||||
|
return () => window.clearInterval(timer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="screen screen--tabs runtime-lab-screen" data-testid="runtime-lab-view" :data-mount-id="mountId">
|
||||||
|
<AppHeader title="Runtime stress lab" subtitle="A cached push-history sibling" back />
|
||||||
|
|
||||||
|
<section class="lab-intro lab-intro--timer">
|
||||||
|
<span>{{ mountedSeconds }}</span>
|
||||||
|
<div>
|
||||||
|
<strong>Seconds mounted</strong>
|
||||||
|
<p
|
||||||
|
data-testid="mounted-seconds"
|
||||||
|
:data-seconds="mountedSeconds"
|
||||||
|
>Elapsed mount time is preserved, while interval work pauses whenever this view is inactive.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-group">
|
||||||
|
<h2>Suspense boundary</h2>
|
||||||
|
<Suspense :timeout="0">
|
||||||
|
<AsyncLabData />
|
||||||
|
<template #fallback>
|
||||||
|
<article class="lab-probe lab-probe--loading" data-testid="async-data-loading" aria-live="polite">
|
||||||
|
<span class="lab-spinner" aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<strong>Waiting for async payload</strong>
|
||||||
|
<p>Data becomes available one second after the child mounts.</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
</Suspense>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-group">
|
||||||
|
<h2>Guard and history state</h2>
|
||||||
|
<div>
|
||||||
|
<span><strong>Entry guard</strong><small>Asynchronous check #{{ runtimeLabGuard.checks }}</small></span>
|
||||||
|
<b data-testid="lab-guard-status" :class="{ offline: runtimeLabGuard.status === 'blocked' }">{{ runtimeLabGuard.status }}</b>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span><strong>Sibling history</strong><small>This route pushes instead of replacing Profile</small></span>
|
||||||
|
<b>push</b>
|
||||||
|
</div>
|
||||||
|
<p>Use Back to return to Profile. Because this is a pushed sibling, it is unmounted after its exit animation; replaced primary siblings remain lazily cached instead.</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
@@ -1,9 +1,48 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useNativeRouter } from '@native-vue-router/core'
|
||||||
import AppHeader from '../components/AppHeader.vue'
|
import AppHeader from '../components/AppHeader.vue'
|
||||||
import { useDemoStore } from '../data'
|
import { useDemoStore } from '../data'
|
||||||
import { pwaEnvironment } from '../pwa'
|
import {
|
||||||
|
profilerHasCapture,
|
||||||
|
profilerRecording,
|
||||||
|
shareDemoProfile,
|
||||||
|
startDemoProfile,
|
||||||
|
stopDemoProfile,
|
||||||
|
} from '../navigation-profiler'
|
||||||
|
import { pwaBuildId, pwaEnvironment } from '../pwa'
|
||||||
|
|
||||||
const store = useDemoStore()
|
const store = useDemoStore()
|
||||||
|
const native = useNativeRouter()
|
||||||
|
const unloadResult = ref('Unload cached Stories')
|
||||||
|
const profileStatus = ref(profilerRecording.value ? 'Recording navigation now' : 'Ready to record')
|
||||||
|
|
||||||
|
function describeProfile(report: ReturnType<typeof stopDemoProfile>) {
|
||||||
|
return `${report.transactions.length} navigations · ${report.summary.droppedFrames} dropped frames · ${report.summary.p95FrameMs} ms p95`
|
||||||
|
}
|
||||||
|
|
||||||
|
function startProfile() {
|
||||||
|
startDemoProfile(native)
|
||||||
|
profileStatus.value = 'Recording. Leave this page, reproduce the jank, then return here.'
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopProfile() {
|
||||||
|
profileStatus.value = describeProfile(stopDemoProfile(native))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportProfile() {
|
||||||
|
try {
|
||||||
|
const result = await shareDemoProfile(native)
|
||||||
|
profileStatus.value = `${describeProfile(result.report)} · ${result.method}`
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as DOMException)?.name !== 'AbortError') profileStatus.value = 'Profile export failed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unloadStories() {
|
||||||
|
const count = native.unload('/stories')
|
||||||
|
unloadResult.value = count ? `Unloaded ${count} Stories view` : 'Stories is not cached'
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -17,10 +56,30 @@ const store = useDemoStore()
|
|||||||
<h2>PWA environment</h2>
|
<h2>PWA environment</h2>
|
||||||
<div><span><strong>Display mode</strong><small>Home Screen installation state</small></span><b :class="{ offline: !pwaEnvironment.standalone }">{{ pwaEnvironment.standalone ? 'Standalone' : 'Browser tab' }}</b></div>
|
<div><span><strong>Display mode</strong><small>Home Screen installation state</small></span><b :class="{ offline: !pwaEnvironment.standalone }">{{ pwaEnvironment.standalone ? 'Standalone' : 'Browser tab' }}</b></div>
|
||||||
<div><span><strong>Offline worker</strong><small>Cached application shell</small></span><b :class="{ offline: pwaEnvironment.serviceWorker !== 'ready' }">{{ pwaEnvironment.serviceWorker }}</b></div>
|
<div><span><strong>Offline worker</strong><small>Cached application shell</small></span><b :class="{ offline: pwaEnvironment.serviceWorker !== 'ready' }">{{ pwaEnvironment.serviceWorker }}</b></div>
|
||||||
|
<div><span><strong>App build</strong><small>{{ pwaBuildId }}</small></span><b :class="{ offline: pwaEnvironment.updateState === 'error' }">{{ pwaEnvironment.updateState }} · {{ pwaEnvironment.updateChecks }} checks</b></div>
|
||||||
<div><span><strong>iOS edge ownership</strong><small>Leading-edge touches claimed by this app: {{ pwaEnvironment.edgeClaims }}</small></span><b :class="{ offline: !pwaEnvironment.edgeGuard }">{{ pwaEnvironment.edgeGuard ? 'App reserved' : pwaEnvironment.ios ? 'Install required' : 'Not iOS' }}</b></div>
|
<div><span><strong>iOS edge ownership</strong><small>Leading-edge touches claimed by this app: {{ pwaEnvironment.edgeClaims }}</small></span><b :class="{ offline: !pwaEnvironment.edgeGuard }">{{ pwaEnvironment.edgeGuard ? 'App reserved' : pwaEnvironment.ios ? 'Install required' : 'Not iOS' }}</b></div>
|
||||||
<p v-if="pwaEnvironment.ios && !pwaEnvironment.standalone">In Safari, choose Share → Add to Home Screen, then launch the new icon. Edge interception is intentionally disabled inside a normal browser tab.</p>
|
<p v-if="pwaEnvironment.ios && !pwaEnvironment.standalone">In Safari, choose Share → Add to Home Screen, then launch the new icon. Edge interception is intentionally disabled inside a normal browser tab.</p>
|
||||||
<p v-else-if="pwaEnvironment.ios">The leading edge is reserved before WebKit navigation begins. Open a conversation and drag from the extreme left edge to verify the live back preview.</p>
|
<p v-else-if="pwaEnvironment.ios">The leading edge is reserved before WebKit navigation begins. Open a conversation and drag from the extreme left edge to verify the live back preview.</p>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="settings-group">
|
||||||
|
<h2>Frame pacing profiler</h2>
|
||||||
|
<div><span><strong>{{ profilerRecording ? 'Recording' : 'Profiler idle' }}</strong><small data-testid="profile-status">{{ profileStatus }}</small></span><b :class="{ offline: !profilerRecording }">{{ profilerRecording ? 'LIVE' : 'OFF' }}</b></div>
|
||||||
|
<p>Start here, reproduce the choppy navigation, return here, then stop and export. The JSON includes rAF frame intervals and navigation phases but omits route params, query values, and application data.</p>
|
||||||
|
</section>
|
||||||
|
<div class="profiler-controls">
|
||||||
|
<button type="button" data-testid="profile-start" :disabled="profilerRecording" @click="startProfile">Start profiling</button>
|
||||||
|
<button type="button" data-testid="profile-stop" :disabled="!profilerRecording" @click="stopProfile">Stop</button>
|
||||||
|
<button type="button" data-testid="profile-export" :disabled="!profilerHasCapture" @click="exportProfile">Share JSON</button>
|
||||||
|
</div>
|
||||||
|
<section class="settings-group">
|
||||||
|
<h2>Native view cache</h2>
|
||||||
|
<div><span><strong>Mounted views</strong><small>{{ native.cacheStats.value.inactive }} inactive of {{ native.cacheStats.value.maxInactive }} allowed</small></span><b data-testid="cache-mounted">{{ native.cacheStats.value.mounted }}</b></div>
|
||||||
|
<div><span><strong>Route descriptors</strong><small>{{ native.cacheStats.value.evicted }} currently evicted</small></span><b>{{ native.cacheStats.value.descriptors }}</b></div>
|
||||||
|
<div><span><strong>Total evictions</strong><small>{{ native.cacheStats.value.lastEviction?.reason ?? 'No eviction yet' }}</small></span><b data-testid="cache-evictions">{{ native.cacheStats.value.totalEvictions }}</b></div>
|
||||||
|
<p>Sibling tabs are created on first visit, then retained. Back-stack screens stay warm only while they remain useful as a predictive-back target.</p>
|
||||||
|
</section>
|
||||||
|
<button class="reset-button reset-button--neutral" type="button" @click="native.trimCache()">Trim inactive view cache</button>
|
||||||
|
<button class="reset-button reset-button--neutral" data-testid="unload-stories" type="button" @click="unloadStories">{{ unloadResult }}</button>
|
||||||
<section class="settings-group">
|
<section class="settings-group">
|
||||||
<h2>Simulation</h2>
|
<h2>Simulation</h2>
|
||||||
<label><span><strong>Network latency</strong><small>{{ store.settings.simulatedLatency }} ms</small></span><input v-model.number="store.settings.simulatedLatency" type="range" min="0" max="1200" step="20" /></label>
|
<label><span><strong>Network latency</strong><small>{{ store.settings.simulatedLatency }} ms</small></span><input v-model.number="store.settings.simulatedLatency" type="range" min="0" max="1200" step="20" /></label>
|
||||||
|
|||||||
@@ -1,9 +1,25 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useNativeViewActiveEffect } from '@native-vue-router/core'
|
||||||
import AppAvatar from '../components/AppAvatar.vue'
|
import AppAvatar from '../components/AppAvatar.vue'
|
||||||
import AppHeader from '../components/AppHeader.vue'
|
import AppHeader from '../components/AppHeader.vue'
|
||||||
import { useDemoStore } from '../data'
|
import { useDemoStore } from '../data'
|
||||||
|
|
||||||
const store = useDemoStore()
|
const store = useDemoStore()
|
||||||
|
const mountId = crypto.randomUUID()
|
||||||
|
const mountedAt = Date.now()
|
||||||
|
const mountedSeconds = ref(0)
|
||||||
|
const activeTicks = ref(0)
|
||||||
|
|
||||||
|
useNativeViewActiveEffect(() => {
|
||||||
|
const update = () => {
|
||||||
|
mountedSeconds.value = Math.floor((Date.now() - mountedAt) / 1_000)
|
||||||
|
activeTicks.value += 1
|
||||||
|
}
|
||||||
|
update()
|
||||||
|
const timer = window.setInterval(update, 200)
|
||||||
|
return () => window.clearInterval(timer)
|
||||||
|
})
|
||||||
const gradients = [
|
const gradients = [
|
||||||
'linear-gradient(155deg, #f4a261, #d64e7d 48%, #5427a8)',
|
'linear-gradient(155deg, #f4a261, #d64e7d 48%, #5427a8)',
|
||||||
'linear-gradient(155deg, #44c6e9, #4378db 48%, #1b245d)',
|
'linear-gradient(155deg, #44c6e9, #4378db 48%, #1b245d)',
|
||||||
@@ -13,7 +29,13 @@ const gradients = [
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="screen screen--tabs">
|
<main
|
||||||
|
class="screen screen--tabs"
|
||||||
|
data-testid="stories-view"
|
||||||
|
:data-mount-id="mountId"
|
||||||
|
:data-mounted-seconds="mountedSeconds"
|
||||||
|
:data-active-ticks="activeTicks"
|
||||||
|
>
|
||||||
<AppHeader title="Stories" subtitle="Moments from your circle" large />
|
<AppHeader title="Stories" subtitle="Moments from your circle" large />
|
||||||
<section class="story-grid">
|
<section class="story-grid">
|
||||||
<article v-for="(person, index) in store.people.value.slice(0, 4)" :key="person.id" class="story-card" :style="{ background: gradients[index] }">
|
<article v-for="(person, index) in store.people.value.slice(0, 4)" :key="person.id" class="story-card" :style="{ background: gradients[index] }">
|
||||||
@@ -26,6 +48,6 @@ const gradients = [
|
|||||||
<span class="story-card__mark">{{ ['✦', '◌', '△', '◇'][index] }}</span>
|
<span class="story-card__mark">{{ ['✦', '◌', '△', '◇'][index] }}</span>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
<p class="gesture-tip">Swipe horizontally anywhere to move between primary routes.</p>
|
<p class="gesture-tip">Mounted {{ mountedSeconds }}s · active work {{ activeTicks }} ticks. The tick loop pauses while this sibling is cached.</p>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
103
apps/demo/src/views/VueCompatibilityAwayView.vue
Normal file
103
apps/demo/src/views/VueCompatibilityAwayView.vue
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import { useNativeRouter } from "@native-vue-router/core";
|
||||||
|
import { useRoute } from "vue-router";
|
||||||
|
import AppHeader from "../components/AppHeader.vue";
|
||||||
|
import {
|
||||||
|
compatibilityLab,
|
||||||
|
recordCompatibilityEvent,
|
||||||
|
} from "../compatibility-lab";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const native = useNativeRouter();
|
||||||
|
const unloadStatus = ref("Lab remains cached");
|
||||||
|
const labLocation = computed(() => ({
|
||||||
|
name: "vue-compatibility",
|
||||||
|
params: { sample: route.params.sample },
|
||||||
|
query: route.query,
|
||||||
|
hash: route.hash,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function unloadLab() {
|
||||||
|
const count = native.unload(labLocation.value);
|
||||||
|
unloadStatus.value = count
|
||||||
|
? "Lab view evicted; Back will create a new instance"
|
||||||
|
: "No matching inactive lab view was mounted";
|
||||||
|
recordCompatibilityEvent("Away screen", "native.unload", `${count} view(s)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unloadAndReturn() {
|
||||||
|
unloadLab();
|
||||||
|
await native.pop();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main
|
||||||
|
class="screen compatibility-screen"
|
||||||
|
data-testid="vue-compatibility-away"
|
||||||
|
>
|
||||||
|
<AppHeader
|
||||||
|
title="Cached-route checkpoint"
|
||||||
|
subtitle="The compatibility lab is behind this view"
|
||||||
|
back
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section class="lab-intro compat-intro">
|
||||||
|
<span>↩</span>
|
||||||
|
<div>
|
||||||
|
<strong>Inspect native cache behavior</strong>
|
||||||
|
<p>
|
||||||
|
The previous lab instance is inactive but still mounted until you
|
||||||
|
explicitly unload it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-group">
|
||||||
|
<h2>Inactive route controls</h2>
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
><strong>Compatibility lab</strong
|
||||||
|
><small>{{ unloadStatus }}</small></span
|
||||||
|
><b>behind</b>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
Use normal Back to observe native activate/show without Vue remount
|
||||||
|
hooks. Use “Unload and return” to observe beforeUnmount/unmounted
|
||||||
|
followed by a fresh component instance.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<div class="compat-controls">
|
||||||
|
<button type="button" data-testid="compat-unload-lab" @click="unloadLab">
|
||||||
|
Unload cached lab
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="compat-unload-return"
|
||||||
|
@click="unloadAndReturn"
|
||||||
|
>
|
||||||
|
Unload and return
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="settings-group compat-event-section">
|
||||||
|
<div class="compat-event-heading">
|
||||||
|
<span
|
||||||
|
><strong>Shared lifecycle journal</strong
|
||||||
|
><small>Events survive route eviction</small></span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<ol class="compat-event-log" data-testid="compat-away-event-log">
|
||||||
|
<li v-for="event in compatibilityLab.events" :key="event.id">
|
||||||
|
<time>{{ event.timestamp }}</time>
|
||||||
|
<span
|
||||||
|
><strong>{{ event.source }}</strong
|
||||||
|
><code>{{ event.hook }}</code></span
|
||||||
|
>
|
||||||
|
<small>{{ event.detail }}</small>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
427
apps/demo/src/views/VueCompatibilityView.vue
Normal file
427
apps/demo/src/views/VueCompatibilityView.vue
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
computed,
|
||||||
|
onBeforeUnmount,
|
||||||
|
onMounted,
|
||||||
|
onUnmounted,
|
||||||
|
provide,
|
||||||
|
ref,
|
||||||
|
} from "vue";
|
||||||
|
import {
|
||||||
|
onNativeViewActivate,
|
||||||
|
onNativeViewDeactivate,
|
||||||
|
onNativeViewEvict,
|
||||||
|
onNativeViewHide,
|
||||||
|
onNativeViewShow,
|
||||||
|
useNativeRouter,
|
||||||
|
useNativeViewLifecycle,
|
||||||
|
} from "@native-vue-router/core";
|
||||||
|
import { useRoute } from "vue-router";
|
||||||
|
import AppHeader from "../components/AppHeader.vue";
|
||||||
|
import CompatibilityAsyncProbe from "../components/CompatibilityAsyncProbe.vue";
|
||||||
|
import CompatibilityCompositionProbe from "../components/CompatibilityCompositionProbe.vue";
|
||||||
|
import CompatibilityInjectProbe from "../components/CompatibilityInjectProbe.vue";
|
||||||
|
import CompatibilityKeepAliveProbe from "../components/CompatibilityKeepAliveProbe.vue";
|
||||||
|
import CompatibilityOptionsProbe from "../components/CompatibilityOptionsProbe.vue";
|
||||||
|
import {
|
||||||
|
clearCompatibilityEvents,
|
||||||
|
compatibilityLab,
|
||||||
|
compatibilityLabContextKey,
|
||||||
|
recordCompatibilityEvent,
|
||||||
|
} from "../compatibility-lab";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const native = useNativeRouter();
|
||||||
|
const nativeLifecycle = useNativeViewLifecycle();
|
||||||
|
const instanceId = Math.random().toString(36).slice(2, 7);
|
||||||
|
const revision = ref(0);
|
||||||
|
const probesMounted = ref(true);
|
||||||
|
const keepAliveVariant = ref<"A" | "B">("A");
|
||||||
|
const transitionVisible = ref(true);
|
||||||
|
const teleportOpen = ref(false);
|
||||||
|
const suspenseRequest = ref(1);
|
||||||
|
const routeLabel = computed(() => route.fullPath);
|
||||||
|
const nativeVisible = nativeLifecycle.isVisible;
|
||||||
|
const nativeActive = nativeLifecycle.isActive;
|
||||||
|
const eventCount = computed(() => compatibilityLab.events.length);
|
||||||
|
|
||||||
|
provide(compatibilityLabContextKey, {
|
||||||
|
source: `Vue compatibility lab ${instanceId}`,
|
||||||
|
routeLabel,
|
||||||
|
});
|
||||||
|
|
||||||
|
const recordPageEvent = (hook: string, detail?: string) =>
|
||||||
|
recordCompatibilityEvent(`Lab page ${instanceId}`, hook, detail);
|
||||||
|
|
||||||
|
onMounted(() => recordPageEvent("onMounted", route.fullPath));
|
||||||
|
onBeforeUnmount(() => recordPageEvent("onBeforeUnmount"));
|
||||||
|
onUnmounted(() => recordPageEvent("onUnmounted"));
|
||||||
|
onNativeViewActivate(() => recordPageEvent("native activate"));
|
||||||
|
onNativeViewDeactivate(() => recordPageEvent("native deactivate"));
|
||||||
|
onNativeViewShow(() => recordPageEvent("native show"));
|
||||||
|
onNativeViewHide(() => recordPageEvent("native hide"));
|
||||||
|
onNativeViewEvict((reason) => recordPageEvent("native evict", String(reason)));
|
||||||
|
|
||||||
|
function updateProbes() {
|
||||||
|
revision.value += 1;
|
||||||
|
recordPageEvent("revision changed", String(revision.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleProbeMount() {
|
||||||
|
probesMounted.value = !probesMounted.value;
|
||||||
|
recordPageEvent(probesMounted.value ? "probes inserted" : "probes removed");
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchKeptInstance() {
|
||||||
|
keepAliveVariant.value = keepAliveVariant.value === "A" ? "B" : "A";
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordTransition(hook: string) {
|
||||||
|
recordCompatibilityEvent("Transition", hook);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reloadSuspense() {
|
||||||
|
suspenseRequest.value += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushAlternateRoute() {
|
||||||
|
const sample = route.params.sample === "alpha" ? "beta" : "alpha";
|
||||||
|
void native.push({
|
||||||
|
name: "vue-compatibility",
|
||||||
|
params: { sample },
|
||||||
|
query: { mode: "parameter", revision: revision.value },
|
||||||
|
hash: "#route-state",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceQueryAndHash() {
|
||||||
|
void native.replace(
|
||||||
|
{
|
||||||
|
name: "vue-compatibility",
|
||||||
|
params: { sample: route.params.sample },
|
||||||
|
query: { mode: "replaced", tick: Date.now().toString().slice(-5) },
|
||||||
|
hash: "#event-log",
|
||||||
|
},
|
||||||
|
{ presentation: "fade" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAwayRoute() {
|
||||||
|
void native.push({
|
||||||
|
name: "vue-compatibility-away",
|
||||||
|
params: { sample: route.params.sample },
|
||||||
|
query: route.query,
|
||||||
|
hash: route.hash,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main
|
||||||
|
class="screen compatibility-screen"
|
||||||
|
data-testid="vue-compatibility-view"
|
||||||
|
:data-instance-id="instanceId"
|
||||||
|
>
|
||||||
|
<AppHeader
|
||||||
|
title="Vue compatibility"
|
||||||
|
subtitle="Native component laboratory"
|
||||||
|
back
|
||||||
|
>
|
||||||
|
<span class="compat-header-state" :class="{ active: nativeActive }">{{
|
||||||
|
nativeActive ? "active" : nativeVisible ? "transitioning" : "cached"
|
||||||
|
}}</span>
|
||||||
|
</AppHeader>
|
||||||
|
|
||||||
|
<section class="lab-intro compat-intro">
|
||||||
|
<span>Vue</span>
|
||||||
|
<div>
|
||||||
|
<strong>Exercise real framework behavior</strong>
|
||||||
|
<p>
|
||||||
|
Every control below runs inside a routed, cached NativeRouterView
|
||||||
|
entry.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
id="route-state"
|
||||||
|
class="settings-group compat-route-state"
|
||||||
|
data-testid="compat-route-state"
|
||||||
|
>
|
||||||
|
<h2>Scoped route state</h2>
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
><strong>Full path</strong
|
||||||
|
><small>useRoute() and Options API $route</small></span
|
||||||
|
><code data-testid="compat-full-path">{{ route.fullPath }}</code>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
><strong>Named route</strong
|
||||||
|
><small>Matched record identity</small></span
|
||||||
|
><code>{{ String(route.name) }}</code>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span><strong>Param</strong><small>route.params.sample</small></span
|
||||||
|
><code data-testid="compat-param">{{ route.params.sample }}</code>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span><strong>Query</strong><small>route.query</small></span
|
||||||
|
><code data-testid="compat-query">{{
|
||||||
|
JSON.stringify(route.query)
|
||||||
|
}}</code>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span><strong>Hash</strong><small>route.hash</small></span
|
||||||
|
><code data-testid="compat-hash">{{ route.hash || "(empty)" }}</code>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span><strong>Matched</strong><small>route.matched</small></span
|
||||||
|
><code>{{
|
||||||
|
route.matched.map((record) => String(record.name)).join(" → ")
|
||||||
|
}}</code>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div class="compat-controls compat-controls--three">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="compat-change-param"
|
||||||
|
@click="pushAlternateRoute"
|
||||||
|
>
|
||||||
|
Push alternate param
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="compat-change-query"
|
||||||
|
@click="replaceQueryAndHash"
|
||||||
|
>
|
||||||
|
Replace query + hash
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="compat-open-away"
|
||||||
|
@click="openAwayRoute"
|
||||||
|
>
|
||||||
|
Cache this view
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="settings-group">
|
||||||
|
<h2>provide() / inject()</h2>
|
||||||
|
<CompatibilityInjectProbe location="route tree" />
|
||||||
|
<p>
|
||||||
|
The same probe is rendered inside the Teleport below to verify
|
||||||
|
logical-tree injection and scoped routing.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-group">
|
||||||
|
<h2>Options and Composition lifecycle</h2>
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
><strong>Shared revision</strong
|
||||||
|
><small>Changing it triggers beforeUpdate / updated</small></span
|
||||||
|
><b data-testid="compat-revision">{{ revision }}</b>
|
||||||
|
</div>
|
||||||
|
<div class="compat-inline-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="compat-update-probes"
|
||||||
|
@click="updateProbes"
|
||||||
|
>
|
||||||
|
Update props
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="compat-toggle-probes"
|
||||||
|
@click="toggleProbeMount"
|
||||||
|
>
|
||||||
|
{{ probesMounted ? "Unmount probes" : "Mount probes" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="probesMounted" class="compat-probe-grid">
|
||||||
|
<CompatibilityOptionsProbe
|
||||||
|
:instance-name="`Options ${instanceId}`"
|
||||||
|
:revision="revision"
|
||||||
|
/>
|
||||||
|
<CompatibilityCompositionProbe
|
||||||
|
:instance-name="`Composition ${instanceId}`"
|
||||||
|
:revision="revision"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-group">
|
||||||
|
<h2>KeepAlive</h2>
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
><strong>Current cached child</strong
|
||||||
|
><small>Switch away and back after incrementing</small></span
|
||||||
|
><b>{{ keepAliveVariant }}</b>
|
||||||
|
</div>
|
||||||
|
<div class="compat-inline-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="compat-switch-keepalive"
|
||||||
|
@click="switchKeptInstance"
|
||||||
|
>
|
||||||
|
Switch to {{ keepAliveVariant === "A" ? "B" : "A" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<KeepAlive>
|
||||||
|
<CompatibilityKeepAliveProbe
|
||||||
|
:key="keepAliveVariant"
|
||||||
|
:name="keepAliveVariant"
|
||||||
|
/>
|
||||||
|
</KeepAlive>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-group">
|
||||||
|
<h2>Transition</h2>
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
><strong>CSS transition target</strong
|
||||||
|
><small>Hooks are recorded in the event journal</small></span
|
||||||
|
><b>{{ transitionVisible ? "shown" : "removed" }}</b>
|
||||||
|
</div>
|
||||||
|
<div class="compat-inline-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="compat-toggle-transition"
|
||||||
|
@click="transitionVisible = !transitionVisible"
|
||||||
|
>
|
||||||
|
Toggle transition
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Transition
|
||||||
|
name="compat-fade"
|
||||||
|
@before-enter="recordTransition('before-enter')"
|
||||||
|
@after-enter="recordTransition('after-enter')"
|
||||||
|
@before-leave="recordTransition('before-leave')"
|
||||||
|
@after-leave="recordTransition('after-leave')"
|
||||||
|
>
|
||||||
|
<article
|
||||||
|
v-if="transitionVisible"
|
||||||
|
class="compat-transition-card"
|
||||||
|
data-testid="compat-transition-card"
|
||||||
|
>
|
||||||
|
Transition child is mounted
|
||||||
|
</article>
|
||||||
|
</Transition>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-group">
|
||||||
|
<h2>Teleport</h2>
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
><strong>Body-level overlay</strong
|
||||||
|
><small
|
||||||
|
>Automatically hidden when this native view is inactive</small
|
||||||
|
></span
|
||||||
|
><b>{{ teleportOpen ? "armed" : "closed" }}</b>
|
||||||
|
</div>
|
||||||
|
<div class="compat-inline-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="compat-open-teleport"
|
||||||
|
@click="teleportOpen = true"
|
||||||
|
>
|
||||||
|
Open teleported overlay
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="compat-fade">
|
||||||
|
<div
|
||||||
|
v-if="teleportOpen && nativeVisible"
|
||||||
|
class="compat-teleport"
|
||||||
|
data-testid="compat-teleport-overlay"
|
||||||
|
@click.self="teleportOpen = false"
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="compat-teleport-title"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Close teleported overlay"
|
||||||
|
@click="teleportOpen = false"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<h2 id="compat-teleport-title">Teleported route content</h2>
|
||||||
|
<CompatibilityInjectProbe location="teleport" />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<section class="settings-group">
|
||||||
|
<h2>Suspense</h2>
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
><strong>Async setup request</strong
|
||||||
|
><small>Fallback remains for 750 ms</small></span
|
||||||
|
><b>#{{ suspenseRequest }}</b>
|
||||||
|
</div>
|
||||||
|
<div class="compat-inline-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="compat-reload-suspense"
|
||||||
|
@click="reloadSuspense"
|
||||||
|
>
|
||||||
|
Reload async child
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Suspense
|
||||||
|
:key="suspenseRequest"
|
||||||
|
:timeout="0"
|
||||||
|
@pending="recordCompatibilityEvent('Suspense', 'pending')"
|
||||||
|
@fallback="recordCompatibilityEvent('Suspense', 'fallback')"
|
||||||
|
@resolve="recordCompatibilityEvent('Suspense', 'resolve')"
|
||||||
|
>
|
||||||
|
<CompatibilityAsyncProbe :request-id="suspenseRequest" />
|
||||||
|
<template #fallback>
|
||||||
|
<article
|
||||||
|
class="compat-probe compat-probe--loading"
|
||||||
|
data-testid="compat-suspense-fallback"
|
||||||
|
>
|
||||||
|
<span class="lab-spinner" />
|
||||||
|
<div>
|
||||||
|
<strong>Suspense fallback</strong>
|
||||||
|
<p>Waiting for async setup…</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
</Suspense>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="event-log" class="settings-group compat-event-section">
|
||||||
|
<div class="compat-event-heading">
|
||||||
|
<span
|
||||||
|
><strong>Lifecycle event journal</strong
|
||||||
|
><small>{{ eventCount }} retained events · newest first</small></span
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="compat-clear-events"
|
||||||
|
@click="clearCompatibilityEvents"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ol class="compat-event-log" data-testid="compat-event-log">
|
||||||
|
<li v-for="event in compatibilityLab.events" :key="event.id">
|
||||||
|
<time>{{ event.timestamp }}</time>
|
||||||
|
<span
|
||||||
|
><strong>{{ event.source }}</strong
|
||||||
|
><code>{{ event.hook }}</code></span
|
||||||
|
>
|
||||||
|
<small>{{ event.detail }}</small>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
@@ -61,6 +61,8 @@ function createWindow() {
|
|||||||
{ label: 'Back', accelerator: 'Alt+Left', click: () => window.webContents.send('native-vue:back') },
|
{ label: 'Back', accelerator: 'Alt+Left', click: () => window.webContents.send('native-vue:back') },
|
||||||
{ label: 'Forward', accelerator: 'Alt+Right', click: () => window.webContents.send('native-vue:forward') },
|
{ label: 'Forward', accelerator: 'Alt+Right', click: () => window.webContents.send('native-vue:forward') },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
|
{ label: 'Trim Navigation Cache', click: () => window.webContents.send('native-vue:memory-pressure') },
|
||||||
|
{ type: 'separator' },
|
||||||
{ role: 'reload' },
|
{ role: 'reload' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,4 +9,5 @@ function listener(channel, callback) {
|
|||||||
contextBridge.exposeInMainWorld('nativeVueHost', {
|
contextBridge.exposeInMainWorld('nativeVueHost', {
|
||||||
onBack: (callback) => listener('native-vue:back', callback),
|
onBack: (callback) => listener('native-vue:back', callback),
|
||||||
onForward: (callback) => listener('native-vue:forward', callback),
|
onForward: (callback) => listener('native-vue:forward', callback),
|
||||||
|
onMemoryPressure: (callback) => listener('native-vue:memory-pressure', callback),
|
||||||
})
|
})
|
||||||
|
|||||||
1
apps/origins-demo/.gitignore
vendored
Normal file
1
apps/origins-demo/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
dev-dist
|
||||||
25
apps/origins-demo/index.html
Normal file
25
apps/origins-demo/index.html
Normal 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>
|
||||||
22
apps/origins-demo/package.json
Normal file
22
apps/origins-demo/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
78
apps/origins-demo/src/App.vue
Normal file
78
apps/origins-demo/src/App.vue
Normal 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>
|
||||||
15
apps/origins-demo/src/components/InstanceCard.vue
Normal file
15
apps/origins-demo/src/components/InstanceCard.vue
Normal 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>
|
||||||
37
apps/origins-demo/src/gallery-data.ts
Normal file
37
apps/origins-demo/src/gallery-data.ts
Normal 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.",
|
||||||
|
},
|
||||||
|
];
|
||||||
49
apps/origins-demo/src/lab-state.ts
Normal file
49
apps/origins-demo/src/lab-state.ts
Normal 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 };
|
||||||
|
}
|
||||||
13
apps/origins-demo/src/main.ts
Normal file
13
apps/origins-demo/src/main.ts
Normal 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");
|
||||||
376
apps/origins-demo/src/motions.ts
Normal file
376
apps/origins-demo/src/motions.ts
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* 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" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
2485
apps/origins-demo/src/style.css
Normal file
2485
apps/origins-demo/src/style.css
Normal file
File diff suppressed because it is too large
Load Diff
65
apps/origins-demo/src/views/ChatView.vue
Normal file
65
apps/origins-demo/src/views/ChatView.vue
Normal 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>
|
||||||
116
apps/origins-demo/src/views/DirectionLabView.vue
Normal file
116
apps/origins-demo/src/views/DirectionLabView.vue
Normal 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>
|
||||||
87
apps/origins-demo/src/views/DirectionResultView.vue
Normal file
87
apps/origins-demo/src/views/DirectionResultView.vue
Normal 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>
|
||||||
116
apps/origins-demo/src/views/EdgeDialogView.vue
Normal file
116
apps/origins-demo/src/views/EdgeDialogView.vue
Normal 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>
|
||||||
125
apps/origins-demo/src/views/EdgePredicateView.vue
Normal file
125
apps/origins-demo/src/views/EdgePredicateView.vue
Normal 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>
|
||||||
87
apps/origins-demo/src/views/FirstView.vue
Normal file
87
apps/origins-demo/src/views/FirstView.vue
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<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
|
||||||
|
.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>
|
||||||
63
apps/origins-demo/src/views/FourthView.vue
Normal file
63
apps/origins-demo/src/views/FourthView.vue
Normal 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>
|
||||||
88
apps/origins-demo/src/views/GalleryView.vue
Normal file
88
apps/origins-demo/src/views/GalleryView.vue
Normal 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>
|
||||||
201
apps/origins-demo/src/views/HubView.vue
Normal file
201
apps/origins-demo/src/views/HubView.vue
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
<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 PaymentDetailsView from "./PaymentDetailsView.vue";
|
||||||
|
import PartialDrawerPageView from "./PartialDrawerPageView.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 openCheckout = () =>
|
||||||
|
actionFor(PaymentDetailsView, "Payment Details", slideLeft);
|
||||||
|
const openPartialDrawer = () =>
|
||||||
|
actionFor(PartialDrawerPageView, "Partial Drawer", 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",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: "08",
|
||||||
|
title: "Replace checkout",
|
||||||
|
description:
|
||||||
|
"Confirm a mock payment, replace its mounted entry, then test where back returns.",
|
||||||
|
tags: ["atomic replace", "history rewrite"],
|
||||||
|
action: openCheckout,
|
||||||
|
accent: "teal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: "09",
|
||||||
|
title: "Partial drawer",
|
||||||
|
description:
|
||||||
|
"Push a connected drawer two-thirds across while the live source occupies the final third.",
|
||||||
|
tags: ["persistent effects", "partial view"],
|
||||||
|
action: openPartialDrawer,
|
||||||
|
accent: "orange",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<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>
|
||||||
166
apps/origins-demo/src/views/NestedSceneSlide.vue
Normal file
166
apps/origins-demo/src/views/NestedSceneSlide.vue
Normal 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>
|
||||||
275
apps/origins-demo/src/views/NestedScenesView.vue
Normal file
275
apps/origins-demo/src/views/NestedScenesView.vue
Normal 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>
|
||||||
74
apps/origins-demo/src/views/OrderConfirmationView.vue
Normal file
74
apps/origins-demo/src/views/OrderConfirmationView.vue
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
OriginGesture,
|
||||||
|
back,
|
||||||
|
gesture,
|
||||||
|
slideRight,
|
||||||
|
useOrigin,
|
||||||
|
} from "@native-vue-router/core-v2";
|
||||||
|
import InstanceCard from "../components/InstanceCard.vue";
|
||||||
|
import { useDemoInstance } from "../lab-state";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
orderReference: string;
|
||||||
|
replacedInstance: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const origin = useOrigin();
|
||||||
|
const identity = useDemoInstance("Confirmation");
|
||||||
|
|
||||||
|
const returnGesture = gesture.from
|
||||||
|
.left("clamp(24px, 8%, 64px)")
|
||||||
|
.to.right()
|
||||||
|
.navigate((context) => (context.canGoBack ? back() : null))
|
||||||
|
.animate(slideRight);
|
||||||
|
|
||||||
|
function returnToLabs() {
|
||||||
|
if (origin.context.value.canGoBack) void origin.perform(back(slideRight));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<OriginGesture
|
||||||
|
class="checkout-view confirmation-view"
|
||||||
|
:gesture="returnGesture"
|
||||||
|
>
|
||||||
|
<section class="confirmation-card">
|
||||||
|
<div class="confirmation-mark" aria-hidden="true">✓</div>
|
||||||
|
<div class="eyebrow">Payment complete</div>
|
||||||
|
<h1>Order confirmed.</h1>
|
||||||
|
<p>
|
||||||
|
The Payment Details node <code>{{ replacedInstance }}</code> has been
|
||||||
|
removed. Back now resolves the retained Hub instance directly.
|
||||||
|
</p>
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>Order</dt>
|
||||||
|
<dd>{{ orderReference }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>History depth</dt>
|
||||||
|
<dd>{{ origin.context.value.history.length }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Previous view</dt>
|
||||||
|
<dd>{{ origin.context.value.previous?.name ?? "None" }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<InstanceCard
|
||||||
|
label="Confirmation instance"
|
||||||
|
:instance="identity.instance"
|
||||||
|
:status="identity.status.value"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="confirmation-action"
|
||||||
|
type="button"
|
||||||
|
data-origin-gesture="ignore"
|
||||||
|
@click="returnToLabs"
|
||||||
|
>
|
||||||
|
Back to labs
|
||||||
|
</button>
|
||||||
|
<small>Try the left-edge back gesture too.</small>
|
||||||
|
</section>
|
||||||
|
</OriginGesture>
|
||||||
|
</template>
|
||||||
99
apps/origins-demo/src/views/PartialDrawerPageView.vue
Normal file
99
apps/origins-demo/src/views/PartialDrawerPageView.vue
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
OriginGestureSurface,
|
||||||
|
back,
|
||||||
|
forward,
|
||||||
|
gesture,
|
||||||
|
originView,
|
||||||
|
slideRight,
|
||||||
|
useOrigin,
|
||||||
|
} from "@native-vue-router/core-v2";
|
||||||
|
import InstanceCard from "../components/InstanceCard.vue";
|
||||||
|
import { useDemoInstance } from "../lab-state";
|
||||||
|
import { partialDrawerOpen } from "../motions";
|
||||||
|
import PartialDrawerView from "./PartialDrawerView.vue";
|
||||||
|
|
||||||
|
const origin = useOrigin();
|
||||||
|
const identity = useDemoInstance("Drawer page");
|
||||||
|
|
||||||
|
const drawerView = () =>
|
||||||
|
originView(
|
||||||
|
PartialDrawerView,
|
||||||
|
{ openedFrom: identity.instance },
|
||||||
|
{ key: "partial-drawer", name: "Two-thirds Drawer" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const openDrawerGesture = gesture.from
|
||||||
|
.left("clamp(32px, 10%, 88px)")
|
||||||
|
.to.right()
|
||||||
|
.navigate(() => forward(drawerView()))
|
||||||
|
.animate(partialDrawerOpen);
|
||||||
|
|
||||||
|
const gestures = [openDrawerGesture] as const;
|
||||||
|
|
||||||
|
function openDrawer() {
|
||||||
|
void origin.perform(forward(drawerView(), partialDrawerOpen));
|
||||||
|
}
|
||||||
|
|
||||||
|
function returnToLabs() {
|
||||||
|
if (origin.context.value.canGoBack) void origin.perform(back(slideRight));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<OriginGestureSurface
|
||||||
|
as="main"
|
||||||
|
class="partial-origin-view"
|
||||||
|
:gestures="gestures"
|
||||||
|
>
|
||||||
|
<!--
|
||||||
|
This rail is the left third of the full page. Once the whole page moves
|
||||||
|
right by 2/3, this exact live DOM remains visible in the final third.
|
||||||
|
-->
|
||||||
|
<aside class="partial-origin-rail">
|
||||||
|
<div class="partial-brand">OV</div>
|
||||||
|
<nav aria-label="Example navigation">
|
||||||
|
<button type="button" data-origin-gesture="ignore">Inbox</button>
|
||||||
|
<button type="button" data-origin-gesture="ignore">Projects</button>
|
||||||
|
<button type="button" data-origin-gesture="ignore">Archive</button>
|
||||||
|
</nav>
|
||||||
|
<InstanceCard
|
||||||
|
label="Page instance"
|
||||||
|
:instance="identity.instance"
|
||||||
|
:status="identity.status.value"
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="partial-origin-content">
|
||||||
|
<header>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-origin-gesture="ignore"
|
||||||
|
aria-label="Return to labs"
|
||||||
|
@click="returnToLabs"
|
||||||
|
>
|
||||||
|
← Labs
|
||||||
|
</button>
|
||||||
|
<span>Connected presentation</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="partial-origin-copy">
|
||||||
|
<div class="eyebrow">Partial route lab</div>
|
||||||
|
<h1>Keep one third of this page on screen.</h1>
|
||||||
|
<p>
|
||||||
|
Drag right from the left edge. The drawer becomes a real history
|
||||||
|
entry, while this same mounted page remains translated and visible.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
class="primary-action"
|
||||||
|
type="button"
|
||||||
|
data-origin-gesture="ignore"
|
||||||
|
@click="openDrawer"
|
||||||
|
>
|
||||||
|
Open two-thirds drawer
|
||||||
|
</button>
|
||||||
|
<div class="gesture-hint"><span>→</span> Pull from the left edge</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</OriginGestureSurface>
|
||||||
|
</template>
|
||||||
84
apps/origins-demo/src/views/PartialDrawerView.vue
Normal file
84
apps/origins-demo/src/views/PartialDrawerView.vue
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
OriginGesture,
|
||||||
|
back,
|
||||||
|
gesture,
|
||||||
|
useOrigin,
|
||||||
|
} from "@native-vue-router/core-v2";
|
||||||
|
import InstanceCard from "../components/InstanceCard.vue";
|
||||||
|
import { useDemoInstance } from "../lab-state";
|
||||||
|
import { partialDrawerClose } from "../motions";
|
||||||
|
|
||||||
|
defineProps<{ openedFrom: string }>();
|
||||||
|
|
||||||
|
const origin = useOrigin();
|
||||||
|
const identity = useDemoInstance("Drawer");
|
||||||
|
|
||||||
|
const closeGesture = gesture.to
|
||||||
|
.left()
|
||||||
|
.navigate((context) => (context.canGoBack ? back() : null))
|
||||||
|
.animate(partialDrawerClose);
|
||||||
|
|
||||||
|
function closeDrawer() {
|
||||||
|
if (origin.context.value.canGoBack)
|
||||||
|
void origin.perform(back(partialDrawerClose));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<OriginGesture class="partial-drawer-layer" :gesture="closeGesture">
|
||||||
|
<aside class="partial-drawer-panel">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<div class="eyebrow">Two-thirds destination</div>
|
||||||
|
<h1>Workspace</h1>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-origin-gesture="ignore"
|
||||||
|
aria-label="Close drawer"
|
||||||
|
@click="closeDrawer"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
This drawer is its own Vue view and history entry. It did not copy,
|
||||||
|
teleport, or remount the page visible beside it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="partial-drawer-list">
|
||||||
|
<button type="button" data-origin-gesture="ignore">
|
||||||
|
<span>01</span>
|
||||||
|
Recent activity
|
||||||
|
</button>
|
||||||
|
<button type="button" data-origin-gesture="ignore">
|
||||||
|
<span>02</span>
|
||||||
|
Shared with me
|
||||||
|
</button>
|
||||||
|
<button type="button" data-origin-gesture="ignore">
|
||||||
|
<span>03</span>
|
||||||
|
Offline files
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<InstanceCard
|
||||||
|
label="Drawer instance"
|
||||||
|
:instance="identity.instance"
|
||||||
|
:status="identity.status.value"
|
||||||
|
/>
|
||||||
|
<small>
|
||||||
|
Opened over {{ openedFrom }} · swipe left or tap the exposed third to
|
||||||
|
close.
|
||||||
|
</small>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="partial-drawer-backdrop"
|
||||||
|
type="button"
|
||||||
|
aria-label="Close drawer from exposed page"
|
||||||
|
@click="closeDrawer"
|
||||||
|
/>
|
||||||
|
</OriginGesture>
|
||||||
|
</template>
|
||||||
156
apps/origins-demo/src/views/PaymentDetailsView.vue
Normal file
156
apps/origins-demo/src/views/PaymentDetailsView.vue
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import {
|
||||||
|
OriginGestureSurface,
|
||||||
|
back,
|
||||||
|
gesture,
|
||||||
|
originView,
|
||||||
|
replace,
|
||||||
|
slideLeft,
|
||||||
|
slideRight,
|
||||||
|
useOrigin,
|
||||||
|
} from "@native-vue-router/core-v2";
|
||||||
|
import InstanceCard from "../components/InstanceCard.vue";
|
||||||
|
import { useDemoInstance } from "../lab-state";
|
||||||
|
import OrderConfirmationView from "./OrderConfirmationView.vue";
|
||||||
|
|
||||||
|
const origin = useOrigin();
|
||||||
|
const identity = useDemoInstance("Payment");
|
||||||
|
const cardholder = ref("Ada Lovelace");
|
||||||
|
const lastFour = ref("4242");
|
||||||
|
const processing = ref(false);
|
||||||
|
|
||||||
|
function confirmationView() {
|
||||||
|
return originView(
|
||||||
|
OrderConfirmationView,
|
||||||
|
{
|
||||||
|
orderReference: `NVO-${Math.floor(Date.now() / 1000)
|
||||||
|
.toString()
|
||||||
|
.slice(-6)}`,
|
||||||
|
replacedInstance: identity.instance,
|
||||||
|
},
|
||||||
|
{ key: "order-confirmation", name: "Order Confirmation" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmGesture = gesture.to
|
||||||
|
.left()
|
||||||
|
.navigate(() => replace(confirmationView()))
|
||||||
|
.animate(slideLeft);
|
||||||
|
|
||||||
|
const backGesture = gesture.from
|
||||||
|
.left("clamp(24px, 8%, 64px)")
|
||||||
|
.to.right()
|
||||||
|
.navigate((context) => (context.canGoBack ? back() : null))
|
||||||
|
.animate(slideRight);
|
||||||
|
|
||||||
|
const gestures = [confirmGesture, backGesture] as const;
|
||||||
|
|
||||||
|
function returnToLabs() {
|
||||||
|
if (origin.context.value.canGoBack) void origin.perform(back(slideRight));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pay() {
|
||||||
|
if (processing.value) return;
|
||||||
|
processing.value = true;
|
||||||
|
|
||||||
|
// `replace` is one atomic scene operation. Payment Details stays mounted
|
||||||
|
// throughout the animation and is removed only after the commit completes.
|
||||||
|
await origin.perform(replace(confirmationView(), slideLeft));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<OriginGestureSurface
|
||||||
|
as="main"
|
||||||
|
class="checkout-view payment-view"
|
||||||
|
:gestures="gestures"
|
||||||
|
>
|
||||||
|
<header class="checkout-toolbar">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-origin-gesture="ignore"
|
||||||
|
aria-label="Return to labs"
|
||||||
|
@click="returnToLabs"
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<span>Secure checkout</span>
|
||||||
|
<span class="checkout-step">Step 2 of 2</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="checkout-layout">
|
||||||
|
<div class="checkout-copy">
|
||||||
|
<div class="eyebrow">Replace-history lab</div>
|
||||||
|
<h1>Payment details</h1>
|
||||||
|
<p>
|
||||||
|
Completing this mock payment replaces this mounted entry. The
|
||||||
|
confirmation page will point straight back to the Hub.
|
||||||
|
</p>
|
||||||
|
<InstanceCard
|
||||||
|
label="Payment instance"
|
||||||
|
:instance="identity.instance"
|
||||||
|
:status="identity.status.value"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="payment-card" @submit.prevent="pay">
|
||||||
|
<div class="order-line">
|
||||||
|
<span>Origin Pro</span>
|
||||||
|
<strong>$48.00</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Cardholder
|
||||||
|
<input
|
||||||
|
v-model="cardholder"
|
||||||
|
data-origin-gesture="ignore"
|
||||||
|
autocomplete="cc-name"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Card number
|
||||||
|
<span class="card-number">
|
||||||
|
<b>•••• •••• ••••</b>
|
||||||
|
<input
|
||||||
|
v-model="lastFour"
|
||||||
|
data-origin-gesture="ignore"
|
||||||
|
inputmode="numeric"
|
||||||
|
maxlength="4"
|
||||||
|
autocomplete="cc-number"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div class="payment-row">
|
||||||
|
<label>
|
||||||
|
Expiry
|
||||||
|
<input
|
||||||
|
data-origin-gesture="ignore"
|
||||||
|
value="08 / 29"
|
||||||
|
autocomplete="cc-exp"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
CVC
|
||||||
|
<input
|
||||||
|
data-origin-gesture="ignore"
|
||||||
|
value="123"
|
||||||
|
maxlength="4"
|
||||||
|
autocomplete="cc-csc"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="pay-button"
|
||||||
|
type="submit"
|
||||||
|
data-origin-gesture="ignore"
|
||||||
|
:disabled="processing"
|
||||||
|
>
|
||||||
|
{{ processing ? "Confirming…" : "Pay $48.00" }}
|
||||||
|
</button>
|
||||||
|
<small>Or swipe left anywhere outside the form controls.</small>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</OriginGestureSurface>
|
||||||
|
</template>
|
||||||
114
apps/origins-demo/src/views/PhotoDetailView.vue
Normal file
114
apps/origins-demo/src/views/PhotoDetailView.vue
Normal 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>
|
||||||
105
apps/origins-demo/src/views/PlayerView.vue
Normal file
105
apps/origins-demo/src/views/PlayerView.vue
Normal 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>
|
||||||
67
apps/origins-demo/src/views/SecondView.vue
Normal file
67
apps/origins-demo/src/views/SecondView.vue
Normal 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>
|
||||||
|
X→Y supplies Y's inherited coordinate frame. This Y→Z 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>
|
||||||
60
apps/origins-demo/src/views/ThirdView.vue
Normal file
60
apps/origins-demo/src/views/ThirdView.vue
Normal 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 X→Y and Y→Z 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>
|
||||||
11
apps/origins-demo/tsconfig.json
Normal file
11
apps/origins-demo/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
174
apps/origins-demo/vite.config.ts
Normal file
174
apps/origins-demo/vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -8,13 +8,39 @@ A forward drag calls `router.resolve()` and Vue Router's public `loadRouteLocati
|
|||||||
|
|
||||||
Each preview subtree receives a scoped `routeLocationKey`, so `useRoute()` returns preview params even though the global route is not committed. A normal `router.push()` or `replace()` runs only after the gesture chooses to commit. A guard failure cancels the transaction and removes the preview.
|
Each preview subtree receives a scoped `routeLocationKey`, so `useRoute()` returns preview params even though the global route is not committed. A normal `router.push()` or `replace()` runs only after the gesture chooses to commit. A guard failure cancels the transaction and removes the preview.
|
||||||
|
|
||||||
|
Preview routes can contain ordinary Vue `<Suspense>` boundaries. A route may therefore become a live navigation surface immediately, show its fallback while async child setup continues, and preserve the resolved child when the route later moves into the mounted cache. Route guards remain commit-time authority and can still reject that cached destination on a later entry attempt.
|
||||||
|
|
||||||
## Transaction lifecycle
|
## Transaction lifecycle
|
||||||
|
|
||||||
Transactions move through `interactive`, `committing`, `settling`, and cancellation states. They expose normalized progress and velocity plus `fromKey`, `toKey`, direction, presentation, and optional source geometry.
|
Transactions move through `interactive`, `committing`, `settling`, and cancellation states. They expose normalized progress and velocity plus `fromKey`, `toKey`, direction, presentation, and optional source geometry.
|
||||||
|
|
||||||
The runtime deliberately keeps two ledgers. The navigation stack mirrors committed push/replace/pop semantics and is the only source of predictive-back targets. The view cache owns mounted component instances independently, so a replaced tab can be reused without becoming an accidental back destination.
|
The runtime deliberately keeps two ledgers. The navigation stack mirrors committed push/replace/pop semantics and is the only source of predictive-back targets. The view cache owns mounted component instances independently, so a replaced tab can be reused without becoming an accidental back destination.
|
||||||
|
|
||||||
Pointer movement changes only a CSS progress variable. Built-in presentations restrict active-frame work to compositor-friendly properties. Release uses distance/velocity intent and a damped spring. Reduced-motion mode settles immediately.
|
Pointer movement changes only a CSS progress variable. Built-in presentations restrict active-frame work to compositor-friendly properties. When a destination needs a new component tree, the runtime resolves its lazy route, mounts it, and gives the browser a preparation frame before motion starts; cached destinations skip that wait. Velocity is expressed as normalized route progress per second, so gesture behavior remains consistent across screen sizes. Release uses distance/velocity intent and a damped spring whose settling rate follows the user's flick speed. Reduced-motion mode settles immediately.
|
||||||
|
|
||||||
|
Settling animations are interruptible. A new button navigation or recognized gesture waits only for any in-flight Vue Router guard/history commit, immediately finalizes the old visual transaction, and begins from the newly authoritative route. It never waits for the previous spring to finish. Leading-edge back recognition runs in the navigator capture phase so partially visible component layers cannot steal the physical back edge.
|
||||||
|
|
||||||
|
### Runtime domains
|
||||||
|
|
||||||
|
`runtime.ts` is the public transaction coordinator. Its supporting domains live
|
||||||
|
under `packages/core/src/runtime`:
|
||||||
|
|
||||||
|
- `vue-router-bridge.ts` contains all Vue Router-specific integration: route
|
||||||
|
resolution and loading, commit operations, `afterEach` reconciliation,
|
||||||
|
browser-history completion, and the scoped Options API `$route` bridge.
|
||||||
|
- `history-ledger.ts` owns native push, replace, and pop history semantics.
|
||||||
|
- `view-store.ts` owns mounted route entries, active state, preview reuse,
|
||||||
|
eviction, underlay protection, and cache statistics.
|
||||||
|
- `animation.ts` owns gesture commit policy, view preparation, and spring
|
||||||
|
settling.
|
||||||
|
- `presentations.ts` owns built-in and application-defined presentations.
|
||||||
|
- `diagnostics.ts` owns runtime diagnostic subscriptions and event emission.
|
||||||
|
- `route-entry.ts` owns route-entry construction, labels, timestamps, and
|
||||||
|
sibling direction.
|
||||||
|
|
||||||
|
Keeping the Vue Router adapter separate makes the compatibility work visible
|
||||||
|
and prevents history, cache, rendering, and animation policy from accumulating
|
||||||
|
inside the integration layer.
|
||||||
|
|
||||||
Component gesture owners stop propagation before a containing navigator can claim the same pointer. Navigator gestures wait for horizontal intent, use pointer capture, respect form controls and `data-native-gesture="ignore"`, and preserve normal vertical scrolling through `touch-action: pan-y`.
|
Component gesture owners stop propagation before a containing navigator can claim the same pointer. Navigator gestures wait for horizontal intent, use pointer capture, respect form controls and `data-native-gesture="ignore"`, and preserve normal vertical scrolling through `touch-action: pan-y`.
|
||||||
|
|
||||||
@@ -22,14 +48,15 @@ Component gesture owners stop propagation before a containing navigator can clai
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
interface NativeRouteOptions {
|
interface NativeRouteOptions {
|
||||||
navigator?: string
|
navigator?: string;
|
||||||
presentation?: 'push' | 'reveal' | 'slide' | 'fade' | 'modal' | 'sheet' | string
|
presentation?:
|
||||||
parent?: RouteLocationRaw | ((route) => RouteLocationRaw)
|
"push" | "reveal" | "slide" | "fade" | "modal" | "sheet" | string;
|
||||||
siblingGroup?: string
|
parent?: RouteLocationRaw | ((route) => RouteLocationRaw);
|
||||||
siblingOrder?: number
|
siblingGroup?: string;
|
||||||
siblingHistory?: 'push' | 'replace'
|
siblingOrder?: number;
|
||||||
cache?: boolean
|
siblingHistory?: "push" | "replace";
|
||||||
gesture?: boolean | 'edge' | 'full'
|
cache?: boolean | "pin";
|
||||||
|
gesture?: boolean | "edge" | "full";
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -38,19 +65,54 @@ interface NativeRouteOptions {
|
|||||||
## Custom presentations
|
## Custom presentations
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
nativeRouter.registerPresentation(definePresentation({
|
nativeRouter.registerPresentation(
|
||||||
name: 'scale-fade',
|
definePresentation({
|
||||||
axis: 'x',
|
name: "scale-fade",
|
||||||
|
axis: "x",
|
||||||
layerStyle({ role, progress }) {
|
layerStyle({ role, progress }) {
|
||||||
return role === 'to'
|
return role === "to"
|
||||||
? { opacity: progress, transform: `scale(${0.92 + progress * 0.08})` }
|
? { opacity: progress, transform: `scale(${0.92 + progress * 0.08})` }
|
||||||
: { opacity: 1 - progress * 0.4 }
|
: { opacity: 1 - progress * 0.4 };
|
||||||
},
|
},
|
||||||
}))
|
}),
|
||||||
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
Applications can call `beginInteractive()`, `updateInteractive()`, and `finishInteractive()` to drive the same transaction engine from a bespoke recognizer.
|
Applications can call `beginInteractive()`, `updateInteractive()`, and `finishInteractive()` to drive the same transaction engine from a bespoke recognizer.
|
||||||
|
|
||||||
## Cache semantics
|
## Cache semantics
|
||||||
|
|
||||||
The active route and recent inactive routes remain mounted. The default limit is eight inactive views per runtime. Older entries keep their route descriptor but are unmounted and lazily restored when revisited. Application data that must survive eviction belongs in an application store.
|
The cache is lazy: application startup mounts the current route, not every sibling. A replace-style sibling is created when it is first visited or previewed and can then remain mounted without becoming a browser-back entry. Recent history targets can also stay warm so predictive Back restores component-local state such as a scrolled list immediately.
|
||||||
|
|
||||||
|
The default limit is four inactive views per runtime. `cache: false` always unmounts an inactive route, while `cache: 'pin'` exempts it from ordinary LRU and default bulk trimming. A pushed detail route that is popped or dismissed is unmounted after its exit animation unless it is explicitly pinned. If a guard rejects a cached destination, that component tree is evicted because it is no longer a valid navigation target. Older entries keep lightweight route descriptors and are lazily reconstructed if history reaches them again.
|
||||||
|
|
||||||
|
Applications can explicitly release an inactive location with `nativeRouter.unload('/stories')`. It returns the number of component instances unmounted, retains history descriptors, and refuses to unload the active route or either side of an in-progress transition. `trimCache()` remains the bulk operation.
|
||||||
|
|
||||||
|
This is deliberately not implemented with a single Vue `<KeepAlive>`. An interactive transition must render the current and destination route instances concurrently, while one `<KeepAlive>` outlet normally activates one selected child. Separate temporary wrappers would themselves be removed and lose their caches. The runtime therefore owns the small multi-view cache and exposes equivalent route-aware lifecycle signals:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
onNativeViewActivate,
|
||||||
|
onNativeViewDeactivate,
|
||||||
|
onNativeViewEvict,
|
||||||
|
useNativeViewActiveEffect,
|
||||||
|
useNativeViewLifecycle,
|
||||||
|
} from "@native-vue-router/core";
|
||||||
|
|
||||||
|
const view = useNativeViewLifecycle();
|
||||||
|
|
||||||
|
useNativeViewActiveEffect(() => {
|
||||||
|
const timer = startPolling();
|
||||||
|
return () => stopPolling(timer);
|
||||||
|
});
|
||||||
|
|
||||||
|
onNativeViewEvict((reason) => saveDraft(view.route.value, reason));
|
||||||
|
```
|
||||||
|
|
||||||
|
`isActive` means the route is authoritative. `isVisible` also includes either side of an in-progress transition and the visible, inert route beneath a partial sheet. Use `useNativeViewActiveEffect` for polling and other work that should pause in a cached tab, or `useNativeViewVisibleEffect` for work needed during the animation or while painted beneath a sheet. Application data that must survive eviction belongs in an application store.
|
||||||
|
|
||||||
|
## Optional performance profiler
|
||||||
|
|
||||||
|
`createNativeNavigationProfiler(runtime)` correlates `requestAnimationFrame` intervals with timing-safe runtime events: route loading, cold view preparation, transaction start/commit/end, and eviction. It estimates the device's actual refresh interval instead of assuming 60 Hz, then flags frame gaps larger than 1.5 times that baseline. Reports also include Long Tasks, layout shifts, event timing, and resource timing when the host implements those Performance Observer entry types.
|
||||||
|
|
||||||
|
Safari does not currently expose every Chromium performance entry, so rAF cadence and native-router events are the portable ground truth. Visibility changes are retained because backgrounding or the share sheet can throttle rAF and would otherwise resemble dropped frames. Sampling is completely opt-in and capped at 30,000 frames by default. Route labels use record names or declared path patterns, never params or query values.
|
||||||
|
|||||||
204
docs/challenges-and-tradeoffs.md
Normal file
204
docs/challenges-and-tradeoffs.md
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
# Engineering Challenges, Vue Router Limitations, and Trade-offs
|
||||||
|
|
||||||
|
## The fundamental mismatch
|
||||||
|
|
||||||
|
Vue Router is designed around an authoritative current route. A navigation resolves a location, runs guards, updates history, and makes that location current; `<RouterView>` then renders the matching component. That model is correct for normal web navigation.
|
||||||
|
|
||||||
|
Interactive native navigation needs a second, provisional route before any of those semantic effects are committed. The destination must be fully rendered beside the source while the user is still free to cancel. Much of the work in Native Vue Router exists to bridge that mismatch without forking Vue Router or relying on its internals.
|
||||||
|
|
||||||
|
## Challenge 1: showing two route locations at once
|
||||||
|
|
||||||
|
### Limitation
|
||||||
|
|
||||||
|
The usual `<RouterView>` follows the global current route. A CSS transition around a normal router view can animate old and new DOM after navigation, but it cannot naturally provide a live, reversible destination before navigation.
|
||||||
|
|
||||||
|
### Approach
|
||||||
|
|
||||||
|
The runtime resolves and loads the destination, creates a preview entry, and renders explicit router views for both entries using the `route` prop. A scoped `routeLocationKey` is provided inside each entry so descendants using `useRoute()` read the correct route for that surface.
|
||||||
|
|
||||||
|
### Trade-off
|
||||||
|
|
||||||
|
Two component trees may be live simultaneously. Preview components can mount before commit, so their setup and data loading must tolerate cancellation. Irreversible side effects should be tied to committed application state or view activation, not blindly to component mount.
|
||||||
|
|
||||||
|
## Challenge 2: preserving Vue Router authority
|
||||||
|
|
||||||
|
### Limitation
|
||||||
|
|
||||||
|
It would be simpler to maintain a completely separate navigation stack and update the URL afterward, but that would bypass guards, redirects, route encoding, and existing Vue Router integrations.
|
||||||
|
|
||||||
|
### Approach
|
||||||
|
|
||||||
|
The preview phase never mutates Vue Router history. Commit uses public Vue Router navigation operations. The runtime then accepts the actual resulting route, including redirects, as authoritative and discards any stale preview.
|
||||||
|
|
||||||
|
### Trade-off
|
||||||
|
|
||||||
|
The visual runtime must maintain and reconcile a second ledger. This is deliberate duplication of visual/navigation bookkeeping, with explicit invariants to keep the two systems aligned.
|
||||||
|
|
||||||
|
## Challenge 3: predictive back and opaque browser history
|
||||||
|
|
||||||
|
### Limitation
|
||||||
|
|
||||||
|
The browser does not expose a portable array of prior route locations. Calling `router.back()` also does not synchronously reveal the destination. A cold-start deep link may have an external page, another application, or no useful same-app route behind it.
|
||||||
|
|
||||||
|
### Approach
|
||||||
|
|
||||||
|
The runtime records committed native view keys and uses that ledger for warm-session predictive back. Routes can declare a logical `parent` for cold-start prediction. The actual pop still goes through Vue Router/browser history when a real entry exists.
|
||||||
|
|
||||||
|
### Trade-off
|
||||||
|
|
||||||
|
Applications must describe route topology where history alone is insufficient. A declared parent is a product-level relationship, not proof that the corresponding browser history entry exists. Synthetic parents use replace semantics when committed.
|
||||||
|
|
||||||
|
## Challenge 4: guards, redirects, and asynchronous loading
|
||||||
|
|
||||||
|
### Limitation
|
||||||
|
|
||||||
|
Lazy modules, route guards, redirects, and browser pops resolve asynchronously. Meanwhile, gesture input and animation frames continue. A response from an old navigation can arrive after a newer interaction has started.
|
||||||
|
|
||||||
|
### Approach
|
||||||
|
|
||||||
|
Every transaction and begin attempt receives a monotonically increasing identity. Async continuations verify that they still own the current attempt and transaction before mutating state. Guard rejection springs back; redirect results become authoritative; stale previews are removed.
|
||||||
|
|
||||||
|
### Trade-off
|
||||||
|
|
||||||
|
Only one visual transaction is authoritative at a time. The runtime supports rapid sequential interruption, not multiple independent transitions mutating one navigator concurrently.
|
||||||
|
|
||||||
|
## Challenge 5: interruption without a navigation cooldown
|
||||||
|
|
||||||
|
### Limitation
|
||||||
|
|
||||||
|
The easiest animation model locks input until a transition completes. That creates a visible cooldown and feels unlike a native application. Simply cancelling animation promises is unsafe because Vue Router may already be committing history.
|
||||||
|
|
||||||
|
### Approach
|
||||||
|
|
||||||
|
Settling motion and semantic navigation are treated separately. A new interaction can interrupt the spring immediately, but it waits for any already-started Vue Router navigation to resolve. The prior transaction is finalized at the route Vue Router accepted, and the new transaction begins from that authoritative state.
|
||||||
|
|
||||||
|
Pointer recognizers also detach their local state before awaiting anything. Captured progress, velocity, and transaction ID travel with the old release callback, while a new pointer can start cleanly. Both move and release continuations check ownership before cleanup.
|
||||||
|
|
||||||
|
### Trade-off
|
||||||
|
|
||||||
|
A route guard or browser-history operation can still impose real latency because semantic navigation cannot safely be cancelled after the platform has begun it. The library removes animation cooldown; it cannot remove application guard or network latency.
|
||||||
|
|
||||||
|
## Challenge 6: direction and topology for sibling routes
|
||||||
|
|
||||||
|
### Limitation
|
||||||
|
|
||||||
|
Vue Router knows route hierarchy and matching, but not that `/inbox`, `/stories`, and `/profile` are ordered pages on a horizontal strip. Browser history order also does not necessarily match visual tab order.
|
||||||
|
|
||||||
|
### Approach
|
||||||
|
|
||||||
|
The navigator receives its peer route list, while routes declare `siblingOrder` and optional sibling history semantics. Direction is derived from route order. The sibling presentation moves both surfaces one-to-one, rather than placing a new foreground layer over the old one.
|
||||||
|
|
||||||
|
### Trade-off
|
||||||
|
|
||||||
|
Visual topology must be explicit. Automatic inference from route declaration order would be fragile in modular or dynamically registered route sets.
|
||||||
|
|
||||||
|
## Challenge 7: velocity that behaves consistently
|
||||||
|
|
||||||
|
### Limitation
|
||||||
|
|
||||||
|
Raw pointer velocity in pixels per millisecond changes meaning with viewport size. A fixed-duration animation also ignores whether the user released slowly or flicked decisively.
|
||||||
|
|
||||||
|
### Approach
|
||||||
|
|
||||||
|
Velocity is normalized by the gesture surface width or height and expressed as route progress per second. It influences both the commit decision and the rate of the damped settling spring. High-speed springs are advanced in small simulation substeps to avoid numerical instability.
|
||||||
|
|
||||||
|
### Trade-off
|
||||||
|
|
||||||
|
The current spring constants and velocity scaling are shared defaults rather than a fully configurable physics system. Extremely high input is clamped, preserving stability over perfectly reproducing every raw pointer sample.
|
||||||
|
|
||||||
|
## Challenge 8: gesture arbitration
|
||||||
|
|
||||||
|
### Limitation
|
||||||
|
|
||||||
|
A horizontal movement may mean browser back, application back, tab paging, a component action, text selection, or ordinary scrolling. Bubbling alone is insufficient because a partially revealed child layer can steal the physical edge from its navigator.
|
||||||
|
|
||||||
|
### Approach
|
||||||
|
|
||||||
|
Leading-edge back recognition runs in the navigator's capture phase. Component gestures own non-edge drags by stopping propagation. Recognition waits for a directional threshold, then uses pointer capture. Form controls and explicit ignore regions are excluded, while CSS `touch-action` leaves the perpendicular scroll axis available.
|
||||||
|
|
||||||
|
### Trade-off
|
||||||
|
|
||||||
|
Applications must design gesture regions intentionally. Highly interactive canvases, maps, carousels, editors, and nested horizontal scrollers should opt out or provide their own arbitration.
|
||||||
|
|
||||||
|
## Challenge 9: native edge gestures on the web
|
||||||
|
|
||||||
|
### Limitation
|
||||||
|
|
||||||
|
Web content cannot set `WKWebView.allowsBackForwardNavigationGestures`. In a normal iOS Safari tab, the browser may reserve an edge sequence before page JavaScript receives enough input to implement its own predictive back.
|
||||||
|
|
||||||
|
### Approach
|
||||||
|
|
||||||
|
The installed iOS PWA uses a non-passive leading-edge touch guard at capture time and disables horizontal overscroll as far as web content permits. The guard is intentionally inactive in a normal browser tab. Electron disables Chromium overscroll history navigation at the host level. Capacitor supplies the deterministic native container option.
|
||||||
|
|
||||||
|
### Trade-off
|
||||||
|
|
||||||
|
An installed PWA can provide a strong approximation, not an absolute WebKit-level guarantee. Products that require complete ownership of the native back gesture should use the Capacitor host.
|
||||||
|
|
||||||
|
## Challenge 10: view caching without corrupting history
|
||||||
|
|
||||||
|
### Limitation
|
||||||
|
|
||||||
|
Keeping a tab mounted is a rendering concern; deciding whether Back should visit it is a history concern. Treating one list as both caused stale conversations and replaced tabs to appear as incorrect back targets.
|
||||||
|
|
||||||
|
### Approach
|
||||||
|
|
||||||
|
The runtime separates the history-key ledger from the mounted-view cache. A replaced sibling is created lazily and can remain reusable without entering the back path. Popped pushed routes and guard-rejected destinations are evicted; history descriptors remain available for reconstruction. Statuses distinguish active, inactive, preview, and evicted entries. Route-aware active/visible effects give cached components a way to suspend work.
|
||||||
|
|
||||||
|
### Trade-off
|
||||||
|
|
||||||
|
Mounted routes consume memory. The inactive cache is bounded and can be trimmed by the host, while `cache: false` and `cache: 'pin'` make exceptional route policy explicit. Evicted component-local state is not guaranteed to survive. Durable state belongs in Pinia, another store, IndexedDB, or the backend.
|
||||||
|
|
||||||
|
Vue's `<KeepAlive>` was not used as the cache owner. One shared wrapper is designed to select a current child, but a predictive gesture renders two route instances concurrently. Creating independent wrappers per temporary route layer would make wrapper lifetime control cache lifetime and complicate deterministic LRU eviction. The trade-off is a small router-owned cache with lifecycle APIs instead of Vue's built-in activated/deactivated hooks.
|
||||||
|
|
||||||
|
## Challenge 11: accessibility with concurrent routes
|
||||||
|
|
||||||
|
### Limitation
|
||||||
|
|
||||||
|
Two visible DOM subtrees can create duplicate landmarks, focus targets, and screen-reader content. A hidden cached route can also accidentally receive pointer or keyboard input.
|
||||||
|
|
||||||
|
### Approach
|
||||||
|
|
||||||
|
Inactive entries are hidden, `inert`, and `aria-hidden`. Only the active route or interactive pair is presented visually. Back and tab controls remain semantic buttons and links. Reduced-motion preference settles transactions immediately.
|
||||||
|
|
||||||
|
### Trade-off
|
||||||
|
|
||||||
|
Custom presentations and application overlays must preserve these invariants. Focus transfer at commit may still require application-specific handling for complex screens.
|
||||||
|
|
||||||
|
## Challenge 12: reliable PWA updates
|
||||||
|
|
||||||
|
### Limitation
|
||||||
|
|
||||||
|
Service-worker lifecycle, HTTP caching, Safari foreground behavior, and installed Home Screen state can leave an old application shell active even after a deployment. Reloading immediately is also dangerous during an interactive transaction.
|
||||||
|
|
||||||
|
### Approach
|
||||||
|
|
||||||
|
Production builds use automatic worker activation and check on registration, focus, foreground resume, reconnection, and a periodic timer. Reload is deferred until the native transaction is idle. The demo exposes a build ID and update-check count, and local development/preview uses conservative cache headers.
|
||||||
|
|
||||||
|
### Trade-off
|
||||||
|
|
||||||
|
Deployment infrastructure must still avoid long-lived caching for `sw.js` and the HTML shell. A client running code from before the automatic update policy may need one final manual refresh or reinstall; new code cannot retroactively change an old worker's behavior before it is loaded.
|
||||||
|
|
||||||
|
## Current limitations
|
||||||
|
|
||||||
|
- Client-side DOM navigation is the first-class target; SSR and hydration of a live visual stack are not currently a complete feature.
|
||||||
|
- One runtime coordinates one authoritative visual transaction at a time. Independent nested navigation controllers need an explicit ownership design.
|
||||||
|
- `navigator` and `siblingGroup` metadata are reserved topology fields; the current `NativeNavigator` still receives its sibling route list explicitly. Likewise, `gesture: false` is the enforced opt-out, while finer `edge`/`full` policy is primarily expressed by navigator and component structure today.
|
||||||
|
- Route previews may mount application code that later gets cancelled.
|
||||||
|
- Cold-start predictive back requires declared parent topology.
|
||||||
|
- Cache eviction does not preserve arbitrary component-local state.
|
||||||
|
- The browser cannot guarantee native gesture suppression at the same level as Capacitor or a custom `WKWebView`.
|
||||||
|
- Presentation physics are currently library defaults rather than route-by-route configurable tokens.
|
||||||
|
- Long sessions keep lightweight route descriptors in the ledger even when their component trees are evicted. Mounted DOM is bounded, but applications with exceptionally large histories may eventually benefit from indexed lookup and descriptor compaction.
|
||||||
|
- Native appearance is broader than navigation: slow screen rendering, non-native controls, layout shifts, or inappropriate typography can still break the illusion.
|
||||||
|
|
||||||
|
## Summary of deliberate trade-offs
|
||||||
|
|
||||||
|
| Decision | Benefit | Cost |
|
||||||
|
| --------------------------------------------- | ---------------------------------------------------- | ------------------------------------------------------ |
|
||||||
|
| Keep Vue Router authoritative | Guards, URLs, redirects, and ecosystem compatibility | Reconciliation complexity |
|
||||||
|
| Render a preview before commit | Truly interactive and cancellable navigation | Two live component trees and preview side effects |
|
||||||
|
| Maintain separate history and view ledgers | Correct back semantics plus tab caching | More state and invariants |
|
||||||
|
| Interrupt springs but finish semantic commits | No animation cooldown without corrupting history | Guard/history latency can remain |
|
||||||
|
| Require explicit route topology | Deterministic parent and sibling behavior | More route metadata |
|
||||||
|
| Bound mounted views | Predictable DOM and memory use | Component-local state can be evicted |
|
||||||
|
| Use progressive platform adapters | One core across PWA, Electron, and Capacitor | Browser/PWA guarantees remain weaker than native hosts |
|
||||||
130
docs/how-it-works.md
Normal file
130
docs/how-it-works.md
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
# How Native Vue Router Works
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Native Vue Router adds an interactive visual navigation layer to Vue Router. Its goal is not merely to make route changes slide instead of fade. It is designed so navigation itself can be manipulated: a user can reveal a destination, stop halfway, reverse direction, release with velocity, or begin another navigation before the previous animation has settled.
|
||||||
|
|
||||||
|
The library supports four related navigation styles through one transaction engine:
|
||||||
|
|
||||||
|
- Edge-driven predictive back, including drag-and-hold.
|
||||||
|
- One-to-one paging between ordered sibling routes such as primary tabs.
|
||||||
|
- Component-originated navigation, where dragging a row or card reveals its destination.
|
||||||
|
- Presented routes such as modals and sheets, including interactive dismissal.
|
||||||
|
|
||||||
|
Buttons and links use the same engine as gestures. A tab click, back button, programmatic push, and interactive swipe differ only in how progress is supplied and whether the final commit decision is forced.
|
||||||
|
|
||||||
|
## The central idea: semantic state and visual state are different
|
||||||
|
|
||||||
|
Vue Router remains the authority for semantic navigation: route matching, URLs, parameters, redirects, guards, lazy route modules, and browser history. Native Vue Router owns temporary visual state: mounted route surfaces, interactive progress, motion, and the relationship between the surface being left and the surface being revealed.
|
||||||
|
|
||||||
|
That separation produces two ledgers:
|
||||||
|
|
||||||
|
| Ledger | Owns | Source of truth for |
|
||||||
|
| ------------------- | --------------------------------------------------- | ------------------------------------------------- |
|
||||||
|
| Vue Router | Current committed route and browser history | What URL the application is actually on |
|
||||||
|
| Native view runtime | Active, inactive, preview, and evicted view entries | What route surfaces can be rendered during motion |
|
||||||
|
|
||||||
|
The ledgers agree at rest. During a gesture they intentionally diverge: Vue Router still reports the committed `from` route while the native runtime also renders an uncommitted `to` route.
|
||||||
|
|
||||||
|
## Preview before commit
|
||||||
|
|
||||||
|
A forward interaction follows a two-phase process:
|
||||||
|
|
||||||
|
1. Resolve the target with Vue Router.
|
||||||
|
2. Load its lazy route component without pushing a history entry.
|
||||||
|
3. Add a preview entry to the native view ledger.
|
||||||
|
4. Render both the committed route and preview route through explicit `<RouterView :route>` instances.
|
||||||
|
5. Drive their transforms from normalized gesture progress between `0` and `1`.
|
||||||
|
6. On release, decide whether to commit from distance and velocity.
|
||||||
|
7. Only then call Vue Router's `push()`, `replace()`, or browser-backed `back()`.
|
||||||
|
8. Reconcile the visual ledger with the route Vue Router actually accepted.
|
||||||
|
|
||||||
|
If the user reverses the gesture or a navigation guard rejects the target, the preview springs away and is removed. The URL never briefly changes to a route that the user did not commit.
|
||||||
|
|
||||||
|
Each preview subtree receives its own scoped Vue Router route injection. As a result, components rendered in the preview see the preview's params and metadata through `useRoute()`, even though the application's global committed route has not changed yet.
|
||||||
|
|
||||||
|
## Predictive back
|
||||||
|
|
||||||
|
Back navigation is harder than forward navigation because browser history does not expose a reliable, portable list of previous route objects. The runtime therefore maintains its own committed history-key ledger alongside Vue Router.
|
||||||
|
|
||||||
|
When a back gesture starts, the runtime locates the preceding native view entry and renders it beneath the current one. The browser URL remains unchanged while the user drags. On commit, the runtime calls Vue Router/browser back and reconciles with the route that actually wins.
|
||||||
|
|
||||||
|
A cold-start deep link has no in-memory predecessor. Route metadata can declare a logical `parent`, either as a route location or a function of the current route. That gives the runtime a synthetic predictive-back destination without pretending that a browser history entry exists.
|
||||||
|
|
||||||
|
## Transactions
|
||||||
|
|
||||||
|
All navigation motion is represented by a `NativeTransaction`. A transaction identifies:
|
||||||
|
|
||||||
|
- Its kind: push, pop, sibling, present, or dismiss.
|
||||||
|
- The `from` and `to` view keys.
|
||||||
|
- Direction and presentation.
|
||||||
|
- Normalized progress and velocity.
|
||||||
|
- Whether commit uses push or replace semantics.
|
||||||
|
- Its lifecycle phase and optional component source geometry.
|
||||||
|
|
||||||
|
The important phases are interactive, committing, and settling. During the interactive phase, pointer movement directly controls progress. A release changes the phase and starts a damped spring toward either `0` or `1`.
|
||||||
|
|
||||||
|
Velocity is measured as route progress per second rather than pixels per millisecond. A flick therefore behaves consistently on a small phone and a wide desktop window. High release velocity also advances the settling spring faster, so fast intent produces fast completion.
|
||||||
|
|
||||||
|
Transactions are interruptible. Starting a new navigation while a spring is settling does not wait for the old visual animation. The runtime waits only for an in-flight Vue Router history or guard decision, finalizes the old transaction at its authoritative endpoint, and begins the next transaction from that route. Transaction IDs prevent delayed pointer, preload, animation, and navigation callbacks from mutating a newer interaction.
|
||||||
|
|
||||||
|
## Rendering and presentation
|
||||||
|
|
||||||
|
`NativeRouterView` keeps cached route entries as sibling layers. At rest, only the active layer is interactive. A partial sheet also keeps its prior route visible but inert as an underlay. During a transaction, exactly the `from` and `to` entries receive interactive roles.
|
||||||
|
|
||||||
|
Built-in presentations include push, reveal, adjacent-page slide, fade, modal, sheet, and no-motion. Sheets retain their presentation after commit so they remain below the device safe top and can use content height or developer-defined snap points while the inert route beneath stays visible. Partial sheets animate their measured surface instead of a transparent viewport-sized wrapper, preserving motion from the first frame and keeping the underlay scale continuous across commit. Their scroll body hands top/down and bottom/up overscroll to sheet resizing while retaining ordinary content scrolling at interior positions. Sibling slide is deliberately different from a stack push: both pages move one screen-width for one screen-width of gesture progress, so the interaction feels like paging a continuous horizontal surface.
|
||||||
|
|
||||||
|
The runtime publishes progress as a CSS custom property. Built-in motion is mostly expressed through transforms and opacity, keeping per-frame JavaScript work constant. Applications can register presentations whose layer styles are functions of progress, role, direction, and optional source geometry.
|
||||||
|
|
||||||
|
First use has unavoidable setup work—downloading/evaluating a lazy chunk and mounting/layout of its Vue tree—but it does not need to compete with the transition. The runtime completes lazy resolution first, then gives newly mounted destinations a browser preparation frame before advancing progress. Full-surface dimming uses a composited opacity overlay rather than a changing CSS filter so WebKit does not repeatedly rasterize the route subtree.
|
||||||
|
|
||||||
|
## Gesture ownership
|
||||||
|
|
||||||
|
Gesture recognition uses Pointer Events and waits for clear directional intent before claiming a pointer. Vertical scrolling remains available through `touch-action`, while form controls, editable content, and elements marked with `data-native-gesture="ignore"` are excluded.
|
||||||
|
|
||||||
|
Ownership is explicit:
|
||||||
|
|
||||||
|
- The application navigator owns the physical leading edge for back navigation.
|
||||||
|
- A component gesture link owns drags that begin on that component away from the back edge.
|
||||||
|
- A sheet dismissal surface owns downward vertical drags.
|
||||||
|
|
||||||
|
Scrollable sheets choose content or sheet ownership from the gesture's initial directional intent and keep that owner until release. A content-owned gesture does not become a sheet gesture merely because it later reaches an edge or reverses; the next gesture can begin at that edge and resize the sheet. This prevents concurrent scrolling and avoids applying distance accumulated by content to sheet geometry.
|
||||||
|
|
||||||
|
Pointer capture keeps delivery stable after recognition. Recognizer state is detached synchronously at pointer release, before route loading or animation promises are awaited. This is essential: a delayed callback from one gesture must never erase the state of a newer gesture.
|
||||||
|
|
||||||
|
## History and cache are intentionally separate
|
||||||
|
|
||||||
|
Primary sibling routes normally replace one another in history. Their component trees can still remain mounted in the view cache. This means returning to a tab can preserve local UI state without making every tab selection a browser-back destination.
|
||||||
|
|
||||||
|
Siblings are mounted lazily on their first visit or gesture preview, not all at application startup. The cache has a configurable inactive-view limit and an explicit opt-out/pin policy. Popping a pushed route releases its component tree after the exit animation; a rejected cached guard target is evicted immediately. Older entries retain route descriptors but their component trees are unmounted and restored lazily. Durable application data should live in an application store rather than depending on a route component remaining cached forever.
|
||||||
|
|
||||||
|
A normal Vue `<KeepAlive>` is excellent when one outlet selects one child. It is not the cache primitive here because an interactive transition needs two independently addressed route instances to be active at once. Instead, the router owns those sibling view instances and exposes active, visible, cached, and eviction lifecycle signals. This preserves the useful KeepAlive distinction—mounted versus currently active—without coupling navigation history to Vue's single-child activation model.
|
||||||
|
|
||||||
|
## Platform behavior
|
||||||
|
|
||||||
|
The core runtime is host-neutral. Platform adapters add capabilities that a browser-only router should not own:
|
||||||
|
|
||||||
|
- The PWA adapter reserves the leading edge in installed iOS standalone mode as early as web content allows.
|
||||||
|
- The Electron adapter disables Chromium overscroll history navigation and bridges host back/forward commands.
|
||||||
|
- The Capacitor adapter integrates hardware back, deep links, app lifecycle cancellation, root exit, and haptics.
|
||||||
|
|
||||||
|
This is progressive capability, not user-agent imitation. Normal browser tabs still work, but a browser may reserve gestures before JavaScript can claim them. Electron and Capacitor can disable or coordinate host behavior more deterministically.
|
||||||
|
|
||||||
|
## Why this pattern is uncommon
|
||||||
|
|
||||||
|
There is prior work in mobile web navigation and animated router outlets, so the claim is not that interactive routing has never existed. What is unusual is combining Vue Router compatibility, live destination previews, reversible gestures, native-style history semantics, interruption, and multiple hosts in one reusable runtime.
|
||||||
|
|
||||||
|
Several factors make that combination rare:
|
||||||
|
|
||||||
|
1. **Web routers are commit-first.** Their normal unit of work is “change the current location, then render it.” Native interaction needs “render the possible destination, let the user manipulate it, then decide whether location changes.”
|
||||||
|
2. **The platform does not expose a native navigation controller.** Browser history, DOM rendering, pointer recognition, safe areas, service workers, and host gestures are separate systems with separate lifecycles.
|
||||||
|
3. **Two live routes complicate assumptions.** Route injection, focus, accessibility, component side effects, caching, redirects, and scroll ownership all become more difficult when a route is visible but not current.
|
||||||
|
4. **Correct interruption is harder than animation.** A polished demo can lock input while a transition runs. A native-feeling library must accept new intent during route loading, guard resolution, history mutation, and spring settling without stale asynchronous work winning.
|
||||||
|
5. **Host guarantees differ.** A browser tab cannot promise the same edge ownership as a native `WKWebView`, while Electron and Capacitor can change host settings.
|
||||||
|
6. **The implementation cost is disproportionate.** Most web products can accept non-interactive transitions. The additional state machine, testing matrix, memory use, and platform work are justified only when navigation feel is a core product requirement.
|
||||||
|
|
||||||
|
Native Vue Router addresses this by treating interactive navigation as its own stateful system while leaving Vue Router authoritative wherever Vue Router is strongest.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
The library is a client-side navigation runtime, not a replacement for Vue Router and not a native rendering engine. It can closely reproduce native navigation composition and input behavior, but final fidelity still depends on application design, frame performance, platform embedding, typography, safe-area handling, and avoiding expensive work in route components during a gesture.
|
||||||
@@ -2,22 +2,28 @@
|
|||||||
|
|
||||||
## PWA and browser
|
## PWA and browser
|
||||||
|
|
||||||
The demo uses a standalone manifest, safe-area environment variables, and a generated Workbox service worker. Updates are prompted and cannot reload while a gesture is active. `overscroll-behavior` suppresses pull-to-refresh and history overscroll where supported; `touch-action` reserves horizontal manipulation only on navigator-owned surfaces.
|
The demo uses a standalone manifest, Apple Home Screen metadata and PNG icons, safe-area environment variables, and a generated Workbox service worker. Production builds check for updates on startup, focus, foreground resume, network reconnection, and once per minute. A new worker activates automatically; its page reload is deferred until no gesture transaction is active. The Navigation Lab exposes the build ID, update checks, live display mode, service-worker state, edge-guard state, and intercepted-touch count.
|
||||||
|
|
||||||
Mobile operating systems can reserve gestures that web content cannot suppress in every browser mode. The full interaction system targets installed PWAs. Normal tabs retain links, buttons, history, and non-interactive transitions as their fallback.
|
The normal `npm run dev` server enables the development service worker and listens on local network interfaces; no PWA-specific command is required. iOS still requires the resulting address to be delivered through HTTPS before service-worker and Home Screen behavior is available.
|
||||||
|
|
||||||
|
For a production-style update test, deploy successive `npm run build` outputs at the same HTTPS origin. The service-worker entry file must be served without long-lived HTTP caching; fingerprinted files under `assets/` can remain immutable. An already-installed build that predates the automatic updater may require one final manual refresh or reinstall before it can receive the new update policy.
|
||||||
|
|
||||||
|
In an installed iOS Home Screen app, the PWA adapter installs non-passive leading-edge touch listeners before the navigator gesture and applies `overscroll-behavior-x: none`. This gives the application the earliest web-content opportunity to claim the sequence. The guard is disabled in normal Safari tabs so the demo does not unexpectedly override browser navigation.
|
||||||
|
|
||||||
|
Mobile operating systems can reserve gestures before web content receives them. A PWA cannot set `WKWebView.allowsBackForwardNavigationGestures`, so absolute native-level suppression cannot be guaranteed from JavaScript. The full interaction system targets installed PWAs; use the Capacitor host when that native switch must be deterministic. Normal tabs retain links, buttons, history, and non-interactive transitions as their fallback.
|
||||||
|
|
||||||
## Electron
|
## Electron
|
||||||
|
|
||||||
Call `disableElectronHistoryGestures(app.commandLine)` before `app.whenReady()`. It disables Chromium's `OverscrollHistoryNavigation`, preventing the host from racing the renderer's interactive stack. The included preload bridge maps app commands and Alt+Arrow shortcuts into the renderer adapter without enabling Node integration.
|
Call `disableElectronHistoryGestures(app.commandLine)` before `app.whenReady()`. It disables Chromium's `OverscrollHistoryNavigation`, preventing the host from racing the renderer's interactive stack. The included preload bridge maps app commands, memory-pressure notifications, and Alt+Arrow shortcuts into the renderer adapter without enabling Node integration.
|
||||||
|
|
||||||
The demo switches to hash history under `file:` so packaged deep navigation never asks the filesystem for route paths.
|
The demo switches to hash history under `file:` so packaged deep navigation never asks the filesystem for route paths.
|
||||||
|
|
||||||
## Capacitor
|
## Capacitor
|
||||||
|
|
||||||
`createCapacitorAdapter()` handles Android hardware back, Universal/App Links, launch URLs, pause cancellation, root exit, and native haptic feedback.
|
`createCapacitorAdapter()` handles Android hardware back, Universal/App Links, launch URLs, pause cancellation, root exit, and native haptic feedback. It trims inactive views when the native app pauses by default; set `trimCacheOnPause: false` only when the application deliberately prefers warm views over background memory release.
|
||||||
|
|
||||||
The checked-in iOS and Android projects use Capacitor 8 and include App, Haptics, Splash Screen, and Status Bar plugins. Rebuild the web bundle before `npx cap sync`.
|
The checked-in iOS and Android projects use Capacitor 8 and include App, Haptics, Splash Screen, and Status Bar plugins. Rebuild the web bundle before `npx cap sync`.
|
||||||
|
|
||||||
## Accessibility
|
## Accessibility
|
||||||
|
|
||||||
Inactive live routes are `inert` and `aria-hidden`. Only the active or interactive pair participates in focus and pointer hit testing. Back and tab controls retain native link/button semantics; reduced-motion users receive immediate transaction settling. Custom presentations must preserve the same focus and inert invariants.
|
Inactive live routes are `inert` and `aria-hidden`. Only the active or interactive pair participates in focus and pointer hit testing. A partial sheet's visible underlay is also inert and `aria-hidden`; it remains painted only to provide visual context beneath the sheet backdrop. Back and tab controls retain native link/button semantics; reduced-motion users receive immediate transaction settling. Custom presentations must preserve the same focus and inert invariants.
|
||||||
|
|||||||
205
docs/principles-and-scalability.md
Normal file
205
docs/principles-and-scalability.md
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
# Core Principles, Scalability, and Flexibility
|
||||||
|
|
||||||
|
## Core principles
|
||||||
|
|
||||||
|
### 1. Vue Router remains authoritative
|
||||||
|
|
||||||
|
Native Vue Router is a visual transaction system around Vue Router, not a competing URL router. Matching, encoding, guards, redirects, and committed history stay with Vue Router. If the two systems disagree after navigation, Vue Router's accepted route wins.
|
||||||
|
|
||||||
|
This principle preserves compatibility and gives the runtime a clear recovery rule.
|
||||||
|
|
||||||
|
### 2. Preview is not commitment
|
||||||
|
|
||||||
|
A destination may be loaded, mounted, and visible without being current. Gesture progress must never imply semantic commitment. The URL, browser history, analytics, and irreversible business actions should change only when the transaction commits.
|
||||||
|
|
||||||
|
### 3. Gestures are first-class navigation input
|
||||||
|
|
||||||
|
A gesture is not a decorative transition attached after `router.push()`. It begins a navigation candidate, controls its progress, and chooses commit or cancellation. Buttons, links, hardware back, and gestures feed the same runtime so they cannot develop contradictory behavior.
|
||||||
|
|
||||||
|
### 4. Navigation must remain interruptible
|
||||||
|
|
||||||
|
Users should not wait for visual settling before expressing the next intent. Animations are disposable; committed route decisions are not. New input may interrupt presentation immediately while respecting any semantic operation already in flight.
|
||||||
|
|
||||||
|
### 5. Route topology should be explicit
|
||||||
|
|
||||||
|
History order, visual sibling order, and logical parentage are different concepts. Applications declare parent and sibling relationships rather than relying on incidental route registration or click history.
|
||||||
|
|
||||||
|
### 6. History and rendering are separate concerns
|
||||||
|
|
||||||
|
A route can be cached without belonging in the back stack, and a back destination can be reconstructed without remaining mounted. This separation is essential for tabs, sheets, deep links, and bounded memory.
|
||||||
|
|
||||||
|
### 7. Async work never owns state forever
|
||||||
|
|
||||||
|
Every preload, pointer continuation, route commit, and animation frame is conditional on a current attempt or transaction identity. Stale work becomes a no-op. Cleanup is ownership-aware and cannot erase a newer gesture.
|
||||||
|
|
||||||
|
### 8. The active frame should be cheap
|
||||||
|
|
||||||
|
Pointer movement updates normalized progress. Rendering derives from that value. Built-in sibling motion uses only adjacent transforms, and JavaScript work per frame does not grow with route count. Route components should avoid layout churn and heavyweight synchronous work during previews.
|
||||||
|
|
||||||
|
### 9. Host capabilities are adapters, not conditionals scattered through core
|
||||||
|
|
||||||
|
Hardware back, haptics, deep links, WebKit edge behavior, and Electron command-line switches belong at the platform boundary. The transaction model remains the same across hosts.
|
||||||
|
|
||||||
|
### 10. Accessibility is a state invariant
|
||||||
|
|
||||||
|
Cached and preview DOM must not create duplicate interactive applications. Inactive layers are isolated from focus and assistive technology, controls keep native semantics, and reduced-motion behavior is deterministic.
|
||||||
|
|
||||||
|
## Scalability model
|
||||||
|
|
||||||
|
The runtime is intended to scale in four different ways: number of routes, session length, application complexity, and number of host platforms.
|
||||||
|
|
||||||
|
### Route and DOM scale
|
||||||
|
|
||||||
|
Only the active route, previously visited inactive routes, and a transaction preview need mounted component trees. Siblings are not instantiated eagerly at application startup. `maxInactive` bounds the inactive mounted cache; the default is four. Pinned entries are deliberately outside this ordinary budget. Older views are marked evicted and lazily remounted when needed.
|
||||||
|
|
||||||
|
During an interaction, animation work concerns two surfaces regardless of total route count:
|
||||||
|
|
||||||
|
| Resource | Growth behavior |
|
||||||
|
| -------------------------------- | ------------------------------------- |
|
||||||
|
| Animated surfaces | Constant: `from` and `to` |
|
||||||
|
| Mounted inactive component trees | Bounded by `maxInactive` |
|
||||||
|
| Route descriptors/history keys | Grows with navigation history |
|
||||||
|
| Per-frame transaction state | Constant |
|
||||||
|
| Lazy route code | Loaded on first preview or navigation |
|
||||||
|
|
||||||
|
The current implementation uses linear searches through view entries for some reconciliation operations. This is appropriate for ordinary application histories and a small mounted cache. If the runtime is used for sessions with thousands of unique committed entries, a key/path index and descriptor compaction would be a sensible evolution without changing the public transaction model.
|
||||||
|
|
||||||
|
### Data scale
|
||||||
|
|
||||||
|
The view cache is not an application data cache. Large collections, message history, drafts, and durable form state should live outside route component instances. This allows view eviction to remain cheap and makes state available whether a route is reached through a gesture, deep link, background notification, or restored session.
|
||||||
|
|
||||||
|
Cached components should also avoid doing active-screen work indefinitely. Route-aware lifecycle effects let polling, animation loops, media, and subscriptions stop while a component is inactive and resume without losing its local render state. Hosts can call `trimCache()` under memory pressure; the Capacitor adapter does so when the app pauses by default.
|
||||||
|
|
||||||
|
Preview loading should fetch only what the destination needs to render its initial surface. Applications can use route-level lazy imports, shared stores, request deduplication, and cancellation to avoid duplicating expensive work during a cancelled preview.
|
||||||
|
|
||||||
|
### Team and feature scale
|
||||||
|
|
||||||
|
Navigation policy is carried by route metadata and small primitives rather than screen-specific animation code. Feature teams can define:
|
||||||
|
|
||||||
|
- Presentation and gesture policy on their routes.
|
||||||
|
- Logical parents for deep-linked screens.
|
||||||
|
- Sibling order and history behavior for peer lists owned by a navigator.
|
||||||
|
- Component-owned gesture entry points.
|
||||||
|
|
||||||
|
Cross-cutting behavior remains in the runtime. This reduces the risk that every feature implements a slightly different back threshold, history mutation, or animation lock.
|
||||||
|
|
||||||
|
For very large applications, route topology should be assembled from typed feature modules and validated in tests. Parent cycles, duplicate sibling ordering, and incompatible nested gesture regions are application configuration errors and should be caught before runtime.
|
||||||
|
|
||||||
|
### Platform scale
|
||||||
|
|
||||||
|
The `NativePlatformAdapter` interface keeps platform growth additive. A new host can install listeners, provide haptics, or coordinate root exit without changing route matching or presentation code. The existing PWA, Electron, and Capacitor adapters demonstrate three capability levels:
|
||||||
|
|
||||||
|
1. Best-effort control from web content.
|
||||||
|
2. Desktop host control around a web renderer.
|
||||||
|
3. Native mobile container integration.
|
||||||
|
|
||||||
|
Platform-specific policy should not leak into route components unless the product experience genuinely differs.
|
||||||
|
|
||||||
|
## Flexibility and extension points
|
||||||
|
|
||||||
|
### Route metadata
|
||||||
|
|
||||||
|
Metadata expresses topology and defaults close to route definitions:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
path: '/chat/:id',
|
||||||
|
component: () => import('./ChatView.vue'),
|
||||||
|
meta: {
|
||||||
|
native: {
|
||||||
|
parent: '/inbox',
|
||||||
|
presentation: 'push',
|
||||||
|
gesture: 'edge',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ordered peers are passed to `NativeNavigator`. Their routes use `siblingOrder` to derive direction, while `siblingHistory` decides whether selection replaces or grows history. Metadata supplies defaults, while individual runtime calls can override presentation, direction, replacement, and source geometry. `gesture: false` disables navigator gesture handling for a route; finer ownership remains structural in the current implementation.
|
||||||
|
|
||||||
|
### Custom presentations
|
||||||
|
|
||||||
|
Presentation definitions receive only the data needed to derive layer styles:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
nativeRouter.registerPresentation(
|
||||||
|
definePresentation({
|
||||||
|
name: "scale-fade",
|
||||||
|
axis: "x",
|
||||||
|
layerStyle({ role, progress }) {
|
||||||
|
return role === "to"
|
||||||
|
? { opacity: progress, transform: `scale(${0.94 + progress * 0.06})` }
|
||||||
|
: { opacity: 1 - progress * 0.25 };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
A presentation does not decide history or commit. Keeping motion separate from navigation semantics makes new visual styles safer to add.
|
||||||
|
|
||||||
|
### Custom recognizers
|
||||||
|
|
||||||
|
Applications with a bespoke interaction can call:
|
||||||
|
|
||||||
|
- `beginInteractive()` to create and preload a candidate.
|
||||||
|
- `updateInteractive()` with normalized progress and velocity.
|
||||||
|
- `finishInteractive()` to apply the normal commit decision.
|
||||||
|
- `cancelInteractive()` to settle back.
|
||||||
|
|
||||||
|
This permits interactions such as a card expansion, trackpad scrub, keyboard-driven preview, or canvas gesture without duplicating the route transaction machinery.
|
||||||
|
|
||||||
|
Custom recognizers must follow the same ownership rules: one current transaction, normalized input, stale-callback protection, and explicit cancellation on teardown.
|
||||||
|
|
||||||
|
### Visual presets
|
||||||
|
|
||||||
|
The core owns behavior and minimum presentation CSS. Higher-level packages can supply tab bars, back controls, motion tokens, typography, and platform-adaptive appearance. Product teams can replace the preset without replacing the transaction runtime.
|
||||||
|
|
||||||
|
### Platform adapters
|
||||||
|
|
||||||
|
Adapters can install host listeners and optionally provide haptic feedback or root-exit behavior. They should translate host events into runtime operations instead of editing runtime ledgers directly.
|
||||||
|
|
||||||
|
## Adoption patterns
|
||||||
|
|
||||||
|
The architecture supports incremental use:
|
||||||
|
|
||||||
|
1. **Imperative transitions only:** use `push`, `pop`, `sibling`, `present`, and `dismiss` with buttons and links.
|
||||||
|
2. **Predictive back:** wrap the route surface in `NativeNavigator` and declare cold-start parents.
|
||||||
|
3. **Horizontal route paging:** add ordered sibling routes.
|
||||||
|
4. **Component-originated navigation:** wrap selected rows or cards with `NativeGestureLink`.
|
||||||
|
5. **Custom product motion:** register presentations or drive transactions from a custom recognizer.
|
||||||
|
6. **Host integration:** add the PWA, Electron, or Capacitor adapter according to the guarantees required.
|
||||||
|
|
||||||
|
Teams do not need to make every route gesture-driven at once. Route metadata can disable gestures while retaining native runtime navigation.
|
||||||
|
|
||||||
|
## Reliability and testing principles
|
||||||
|
|
||||||
|
Interactive routing failures are temporal, so tests must assert state during transitions rather than only final URLs. The test suite should preserve these invariants:
|
||||||
|
|
||||||
|
- The `from` and `to` routes are both live during a held gesture.
|
||||||
|
- The URL does not change before commit.
|
||||||
|
- Sibling direction follows declared order.
|
||||||
|
- Adjacent siblings remain edge-to-edge at intermediate progress.
|
||||||
|
- Navigating to the active route is a strict no-op.
|
||||||
|
- A guard rejection removes the preview and restores the source.
|
||||||
|
- Back never selects an unrelated cached view.
|
||||||
|
- A new click or gesture can interrupt settling.
|
||||||
|
- Rapid pointer releases cannot leave an orphaned transaction.
|
||||||
|
- Fast, short flicks commit through velocity and settle faster.
|
||||||
|
- Modal and sheet presentation is independent of previous history shape.
|
||||||
|
- PWA worker activation and update checks remain observable.
|
||||||
|
|
||||||
|
Final-state tests alone would miss most of the bugs that make a router feel non-native.
|
||||||
|
|
||||||
|
## Evolution rules
|
||||||
|
|
||||||
|
Future work should preserve the following boundaries:
|
||||||
|
|
||||||
|
- Do not make preview routes authoritative early to simplify animation.
|
||||||
|
- Do not infer back targets from the mounted cache.
|
||||||
|
- Do not make presentation definitions mutate history.
|
||||||
|
- Do not solve platform behavior with host checks scattered through core.
|
||||||
|
- Do not introduce input locks as a substitute for correct interruption.
|
||||||
|
- Do not let stale async cleanup run without verifying ownership.
|
||||||
|
- Do not rely on component mount as proof of committed navigation.
|
||||||
|
|
||||||
|
Likely extensions include configurable spring profiles, indexed ledgers for unusually long sessions, stronger focus restoration, explicit multi-navigator ownership, SSR-safe initial stack hydration, and more platform-specific motion presets. Each can be added while retaining the same central model: preview visually, commit semantically, and reconcile authoritatively.
|
||||||
140
docs/routeless-origins.md
Normal file
140
docs/routeless-origins.md
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
# Routeless origins architecture
|
||||||
|
|
||||||
|
The v2 experiment lives in `packages/core-v2` and its physical test application
|
||||||
|
lives in `apps/origins-demo`.
|
||||||
|
|
||||||
|
## State model
|
||||||
|
|
||||||
|
The scene contains:
|
||||||
|
|
||||||
|
- stable, flat Vue component nodes;
|
||||||
|
- linked mounted-instance history entries;
|
||||||
|
- temporary directed operation edges.
|
||||||
|
|
||||||
|
It does not contain an active route or current view.
|
||||||
|
|
||||||
|
For overlapping operations:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Nodes: X, Y, Z
|
||||||
|
Edges: X → Y
|
||||||
|
Y → Z
|
||||||
|
```
|
||||||
|
|
||||||
|
Each edge records its own progress, velocity, outcome, choreography, and
|
||||||
|
source/target keys.
|
||||||
|
|
||||||
|
## Coordinate composition
|
||||||
|
|
||||||
|
Suppose A is X→Y and B is Y→Z:
|
||||||
|
|
||||||
|
```text
|
||||||
|
visual(X) = A.source
|
||||||
|
visual(Y) = A.target × B.source
|
||||||
|
visual(Z) = A.target × B.target
|
||||||
|
```
|
||||||
|
|
||||||
|
An optional frame effect is included on both sides of an edge:
|
||||||
|
|
||||||
|
```text
|
||||||
|
visual(Y) = A.frame × A.target × B.frame × B.source
|
||||||
|
visual(Z) = A.frame × A.target × B.frame × B.target
|
||||||
|
```
|
||||||
|
|
||||||
|
The implementation emits these operations as one combined transform on each
|
||||||
|
flat host. This has the visual semantics of nested coordinate frames without
|
||||||
|
reparenting Vue component VNodes.
|
||||||
|
|
||||||
|
## Completion
|
||||||
|
|
||||||
|
Committing a forward X→Y:
|
||||||
|
|
||||||
|
1. Keeps X mounted but marks it parked, inert, and visually hidden.
|
||||||
|
2. Places Y in X's former visual graph position.
|
||||||
|
3. Removes the X→Y edge.
|
||||||
|
4. Leaves any Y→Z edge and all mounted descendants intact.
|
||||||
|
|
||||||
|
Committing Y→Z before X→Y is also valid. Z replaces Y as the target of the
|
||||||
|
still-running X edge, after which X→Y effectively becomes X→Z. The unit suite
|
||||||
|
covers both completion orders.
|
||||||
|
|
||||||
|
Committing back from Y to X reveals the existing X node and removes Y. It does
|
||||||
|
not construct X again from its recipe. Cancelling a forward edge restores its
|
||||||
|
source and removes the newly created target branch; cancelling a back edge
|
||||||
|
re-parks its retained target.
|
||||||
|
|
||||||
|
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
|
||||||
|
`OriginGesture` or `useOriginGesture()`. The injected scene-node key determines
|
||||||
|
the origin. Recognition never asks a coordinator which view is active.
|
||||||
|
|
||||||
|
The immutable `gesture` builder separates optional pointer-down policy
|
||||||
|
(`.from`), movement recognition (`.to`), release policy (`.complete`),
|
||||||
|
navigation intent (`.navigate`), and visual choreography (`.animate`). A chain
|
||||||
|
that begins at `.to` is valid and admits pointer-down anywhere on its host.
|
||||||
|
|
||||||
|
At pointer release, the operation decides synchronously whether it will commit
|
||||||
|
or cancel. Its spring may continue afterward. A retained target can therefore
|
||||||
|
originate another routine while the preceding spring is still visible.
|
||||||
|
|
||||||
|
### Nested scenes
|
||||||
|
|
||||||
|
An `OriginScene` may be rendered inside a view owned by another scene. The
|
||||||
|
nearest injected node scope makes carousel or deck gestures operate on the
|
||||||
|
nested scene, with local measurements and retained history.
|
||||||
|
|
||||||
|
Gesture ownership between nested scenes is currently selected at pointer-down.
|
||||||
|
An eligible child stops propagation even if its later navigation factory
|
||||||
|
declines. Parent fallback therefore requires the child to reserve a
|
||||||
|
non-matching `.from` region; automatic delayed arbitration remains future
|
||||||
|
gesture-arena work.
|
||||||
|
|
||||||
|
## Retained history
|
||||||
|
|
||||||
|
Each pushed node stores the key of its mounted previous entry. The chain is
|
||||||
|
local to that origin context rather than a URL:
|
||||||
|
|
||||||
|
```text
|
||||||
|
X (parked) ← Y (parked) ← Z (visible)
|
||||||
|
```
|
||||||
|
|
||||||
|
Back targets the previous node key directly. A node is unmounted only when a
|
||||||
|
committed back operation pops it, a forward operation is cancelled, or the
|
||||||
|
current entry is successfully replaced, or the whole scene is destroyed.
|
||||||
|
Because the same DOM survives parking, nested scroll positions and
|
||||||
|
component-local state survive without `<KeepAlive>`.
|
||||||
38
experiments/router-guard-history/README.md
Normal file
38
experiments/router-guard-history/README.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Vue Router guard/history experiment
|
||||||
|
|
||||||
|
This small app compares the browser's current URL and history position with
|
||||||
|
Vue Router's `currentRoute` while a real `beforeResolve` guard is pending.
|
||||||
|
|
||||||
|
## Run it
|
||||||
|
|
||||||
|
From the repository root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm --prefix experiments/router-guard-history run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open the URL printed by Vite.
|
||||||
|
|
||||||
|
## Suggested tests
|
||||||
|
|
||||||
|
### Forward navigation
|
||||||
|
|
||||||
|
1. Enable **Pause the next `beforeResolve`**.
|
||||||
|
2. Click **Push Alpha**.
|
||||||
|
3. While paused, compare **Browser URL** with **router.currentRoute**.
|
||||||
|
4. Allow or reject the navigation.
|
||||||
|
|
||||||
|
For `router.push()`, both values remain on the outgoing route until the guard
|
||||||
|
allows confirmation.
|
||||||
|
|
||||||
|
### Back navigation
|
||||||
|
|
||||||
|
1. Push **Alpha**, then push **Beta**.
|
||||||
|
2. Enable **Pause the next `beforeResolve`**.
|
||||||
|
3. Click **Router Back**.
|
||||||
|
4. While paused, compare **Browser URL** with **router.currentRoute**.
|
||||||
|
5. Reject the navigation and watch the browser restore its previous history
|
||||||
|
entry, or repeat the test and allow it.
|
||||||
|
|
||||||
|
The event timeline separately records the raw browser `popstate`, Vue Router
|
||||||
|
guards, the URL, `currentRoute`, and the navigation result.
|
||||||
12
experiments/router-guard-history/index.html
Normal file
12
experiments/router-guard-history/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Vue Router Guard History Experiment</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
18
experiments/router-guard-history/package.json
Normal file
18
experiments/router-guard-history/package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "router-guard-history-experiment",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc -b && vite build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^6.0.7",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.1.1",
|
||||||
|
"vue": "^3.5.39",
|
||||||
|
"vue-router": "^5.0.6",
|
||||||
|
"vue-tsc": "^3.3.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
138
experiments/router-guard-history/src/App.vue
Normal file
138
experiments/router-guard-history/src/App.vue
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import { RouterView } from "vue-router";
|
||||||
|
import { experiment, record } from "./experiment";
|
||||||
|
import { router } from "./router";
|
||||||
|
|
||||||
|
const renderTick = ref(0);
|
||||||
|
const browserUrl = computed(() => {
|
||||||
|
renderTick.value;
|
||||||
|
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||||
|
});
|
||||||
|
const historyPosition = computed(() => {
|
||||||
|
renderTick.value;
|
||||||
|
return window.history.state?.position ?? "not available";
|
||||||
|
});
|
||||||
|
|
||||||
|
function refreshBrowserState() {
|
||||||
|
renderTick.value += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("popstate", refreshBrowserState);
|
||||||
|
router.afterEach(refreshBrowserState);
|
||||||
|
|
||||||
|
function navigate(kind: "push" | "replace", target: string) {
|
||||||
|
record(`action: ${kind}(${target})`, router.currentRoute.value.fullPath, {
|
||||||
|
target,
|
||||||
|
});
|
||||||
|
void router[kind](target).then(() => refreshBrowserState());
|
||||||
|
}
|
||||||
|
|
||||||
|
function back() {
|
||||||
|
record("action: router.back()", router.currentRoute.value.fullPath);
|
||||||
|
router.back();
|
||||||
|
}
|
||||||
|
|
||||||
|
function settle(allow: boolean) {
|
||||||
|
experiment.pendingGuard?.settle(allow);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLog() {
|
||||||
|
experiment.logs.splice(0);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main>
|
||||||
|
<header>
|
||||||
|
<p class="eyebrow">Vue Router experiment</p>
|
||||||
|
<h1>What changes while <code>beforeResolve</code> is pending?</h1>
|
||||||
|
<p class="intro">
|
||||||
|
Pause the next resolve guard, navigate, and compare the browser URL with
|
||||||
|
Vue Router's authoritative route.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="status-grid">
|
||||||
|
<div>
|
||||||
|
<span>Browser URL</span>
|
||||||
|
<strong>{{ browserUrl }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>router.currentRoute</span>
|
||||||
|
<strong>{{ router.currentRoute.value.fullPath }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>history.state.position</span>
|
||||||
|
<strong>{{ historyPosition }}</strong>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="controls">
|
||||||
|
<label class="hold-toggle">
|
||||||
|
<input v-model="experiment.holdNextResolve" type="checkbox" />
|
||||||
|
Pause the next <code>beforeResolve</code>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="button-row">
|
||||||
|
<button @click="navigate('push', '/home')">Push Home</button>
|
||||||
|
<button @click="navigate('push', '/alpha')">Push Alpha</button>
|
||||||
|
<button @click="navigate('push', '/beta')">Push Beta</button>
|
||||||
|
<button @click="navigate('replace', '/alpha')">Replace Alpha</button>
|
||||||
|
<button class="back" @click="back">Router Back</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="experiment.pendingGuard" class="gate">
|
||||||
|
<div>
|
||||||
|
<span>Navigation paused</span>
|
||||||
|
<strong>
|
||||||
|
{{ experiment.pendingGuard.from }} →
|
||||||
|
{{ experiment.pendingGuard.to }}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<button class="allow" @click="settle(true)">Allow navigation</button>
|
||||||
|
<button class="reject" @click="settle(false)">Reject navigation</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<RouterView />
|
||||||
|
|
||||||
|
<section class="log-panel">
|
||||||
|
<div class="log-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Event timeline</p>
|
||||||
|
<h2>Newest event first</h2>
|
||||||
|
</div>
|
||||||
|
<button class="quiet" @click="clearLog">Clear log</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="log-table">
|
||||||
|
<div class="log-row log-labels">
|
||||||
|
<span>Time / event</span>
|
||||||
|
<span>Browser URL</span>
|
||||||
|
<span>Current route</span>
|
||||||
|
<span>Target / result</span>
|
||||||
|
</div>
|
||||||
|
<div v-for="entry in experiment.logs" :key="entry.id" class="log-row">
|
||||||
|
<span
|
||||||
|
><small>{{ entry.elapsed }}</small
|
||||||
|
>{{ entry.event }}</span
|
||||||
|
>
|
||||||
|
<code>{{ entry.browserUrl }}</code>
|
||||||
|
<code>{{ entry.currentRoute }}</code>
|
||||||
|
<span>
|
||||||
|
<code v-if="entry.target">{{ entry.target }}</code>
|
||||||
|
<small v-if="entry.detail">{{ entry.detail }}</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside>
|
||||||
|
<strong>Suggested test</strong>
|
||||||
|
Push Alpha, then Beta. Enable the pause checkbox and click Router Back.
|
||||||
|
While the guard is paused, inspect the two route values and then try both
|
||||||
|
rejection and approval.
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
78
experiments/router-guard-history/src/experiment.ts
Normal file
78
experiments/router-guard-history/src/experiment.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { reactive } from "vue";
|
||||||
|
import type {
|
||||||
|
NavigationFailure,
|
||||||
|
RouteLocationNormalizedLoaded,
|
||||||
|
} from "vue-router";
|
||||||
|
|
||||||
|
export interface ExperimentLog {
|
||||||
|
id: number;
|
||||||
|
elapsed: string;
|
||||||
|
event: string;
|
||||||
|
browserUrl: string;
|
||||||
|
currentRoute: string;
|
||||||
|
target?: string;
|
||||||
|
historyPosition?: unknown;
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingGuard {
|
||||||
|
to: string;
|
||||||
|
from: string;
|
||||||
|
settle(allow: boolean): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedAt = performance.now();
|
||||||
|
let sequence = 0;
|
||||||
|
|
||||||
|
export const experiment = reactive<{
|
||||||
|
holdNextResolve: boolean;
|
||||||
|
pendingGuard: PendingGuard | null;
|
||||||
|
logs: ExperimentLog[];
|
||||||
|
}>({
|
||||||
|
holdNextResolve: false,
|
||||||
|
pendingGuard: null,
|
||||||
|
logs: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
function browserUrl() {
|
||||||
|
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function record(
|
||||||
|
event: string,
|
||||||
|
currentRoute: string,
|
||||||
|
options: { target?: string; detail?: string } = {},
|
||||||
|
) {
|
||||||
|
experiment.logs.unshift({
|
||||||
|
id: ++sequence,
|
||||||
|
elapsed: `${(performance.now() - startedAt).toFixed(1)} ms`,
|
||||||
|
event,
|
||||||
|
browserUrl: browserUrl(),
|
||||||
|
currentRoute,
|
||||||
|
target: options.target,
|
||||||
|
historyPosition: window.history.state?.position,
|
||||||
|
detail: options.detail,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function waitForDecision(
|
||||||
|
to: RouteLocationNormalizedLoaded,
|
||||||
|
from: RouteLocationNormalizedLoaded,
|
||||||
|
) {
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
experiment.pendingGuard = {
|
||||||
|
to: to.fullPath,
|
||||||
|
from: from.fullPath,
|
||||||
|
settle(allow) {
|
||||||
|
experiment.pendingGuard = null;
|
||||||
|
resolve(allow);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function describeFailure(failure?: NavigationFailure | void) {
|
||||||
|
return failure
|
||||||
|
? `navigation failure type ${String(failure.type)}`
|
||||||
|
: "navigation confirmed";
|
||||||
|
}
|
||||||
13
experiments/router-guard-history/src/main.ts
Normal file
13
experiments/router-guard-history/src/main.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { createApp } from "vue";
|
||||||
|
import App from "./App.vue";
|
||||||
|
import { record } from "./experiment";
|
||||||
|
import { router } from "./router";
|
||||||
|
import "./style.css";
|
||||||
|
|
||||||
|
const app = createApp(App);
|
||||||
|
app.use(router);
|
||||||
|
app.mount("#app");
|
||||||
|
|
||||||
|
void router.isReady().then(() => {
|
||||||
|
record("router: ready", router.currentRoute.value.fullPath);
|
||||||
|
});
|
||||||
71
experiments/router-guard-history/src/router.ts
Normal file
71
experiments/router-guard-history/src/router.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import {
|
||||||
|
createRouter,
|
||||||
|
createWebHistory,
|
||||||
|
type RouteRecordRaw,
|
||||||
|
} from "vue-router";
|
||||||
|
import ExperimentPage from "./views/ExperimentPage.vue";
|
||||||
|
import {
|
||||||
|
describeFailure,
|
||||||
|
experiment,
|
||||||
|
record,
|
||||||
|
waitForDecision,
|
||||||
|
} from "./experiment";
|
||||||
|
|
||||||
|
const routes: RouteRecordRaw[] = [
|
||||||
|
{ path: "/", redirect: "/home" },
|
||||||
|
{
|
||||||
|
path: "/home",
|
||||||
|
component: ExperimentPage,
|
||||||
|
props: { title: "Home", color: "#6d5efc" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/alpha",
|
||||||
|
component: ExperimentPage,
|
||||||
|
props: { title: "Alpha", color: "#ef5da8" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/beta",
|
||||||
|
component: ExperimentPage,
|
||||||
|
props: { title: "Beta", color: "#2bbf8a" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes,
|
||||||
|
});
|
||||||
|
|
||||||
|
// This listener sees the raw browser traversal. For a back/forward traversal,
|
||||||
|
// compare its URL with router.currentRoute in the event log.
|
||||||
|
window.addEventListener("popstate", () => {
|
||||||
|
record("window: popstate", router.currentRoute.value.fullPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.beforeEach((to) => {
|
||||||
|
record("router: beforeEach", router.currentRoute.value.fullPath, {
|
||||||
|
target: to.fullPath,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.beforeResolve(async (to, from) => {
|
||||||
|
record("router: beforeResolve entered", router.currentRoute.value.fullPath, {
|
||||||
|
target: to.fullPath,
|
||||||
|
});
|
||||||
|
if (!experiment.holdNextResolve) return;
|
||||||
|
|
||||||
|
experiment.holdNextResolve = false;
|
||||||
|
const allow = await waitForDecision(to, from);
|
||||||
|
record(
|
||||||
|
allow ? "beforeResolve: allowed" : "beforeResolve: rejected",
|
||||||
|
router.currentRoute.value.fullPath,
|
||||||
|
{ target: to.fullPath },
|
||||||
|
);
|
||||||
|
return allow || false;
|
||||||
|
});
|
||||||
|
|
||||||
|
router.afterEach((to, _from, failure) => {
|
||||||
|
record("router: afterEach", router.currentRoute.value.fullPath, {
|
||||||
|
target: to.fullPath,
|
||||||
|
detail: describeFailure(failure),
|
||||||
|
});
|
||||||
|
});
|
||||||
256
experiments/router-guard-history/src/style.css
Normal file
256
experiments/router-guard-history/src/style.css
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
:root {
|
||||||
|
color: #e8e9f3;
|
||||||
|
background: #11131a;
|
||||||
|
font-family:
|
||||||
|
Inter,
|
||||||
|
ui-sans-serif,
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
"Segoe UI",
|
||||||
|
sans-serif;
|
||||||
|
font-synthesis: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 1px solid #3b3e4c;
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
padding: 0.7rem 0.95rem;
|
||||||
|
color: #f5f5fa;
|
||||||
|
background: #252834;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background: #303442;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
width: min(1180px, calc(100% - 2rem));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 3rem 0 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
max-width: 760px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
p {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-size: clamp(2rem, 5vw, 4.25rem);
|
||||||
|
line-height: 0.98;
|
||||||
|
letter-spacing: -0.055em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-bottom: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow,
|
||||||
|
.status-grid span,
|
||||||
|
.gate span,
|
||||||
|
.route-page span,
|
||||||
|
small {
|
||||||
|
color: #979baa;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro {
|
||||||
|
color: #aeb1be;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 2rem 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-grid div,
|
||||||
|
.controls,
|
||||||
|
.log-panel,
|
||||||
|
aside {
|
||||||
|
border: 1px solid #292c38;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: #181a22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-grid div {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-grid strong,
|
||||||
|
.gate strong {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hold-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row,
|
||||||
|
.gate {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate {
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.85rem;
|
||||||
|
border: 1px solid #d6a53a;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: #2a2418;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate div {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.allow {
|
||||||
|
background: #176b4f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reject {
|
||||||
|
background: #7d2d46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-page {
|
||||||
|
min-height: 170px;
|
||||||
|
display: grid;
|
||||||
|
align-content: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding: 1.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 90% 20%, var(--route-color), transparent 42%),
|
||||||
|
#1b1d27;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-page strong {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-panel {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid #292c38;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-heading .eyebrow {
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiet {
|
||||||
|
padding: 0.45rem 0.7rem;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-table {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-row {
|
||||||
|
min-width: 850px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.35fr 1fr 1fr 1.4fr;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
border-top: 1px solid #232630;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-row > span {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-labels {
|
||||||
|
color: #7f8390;
|
||||||
|
border-top: 0;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
color: #d6cffd;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
aside {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
color: #aeb1be;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
aside strong {
|
||||||
|
color: #f5f5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
main {
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
title: string;
|
||||||
|
color: string;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article class="route-page" :style="{ '--route-color': color }">
|
||||||
|
<span>Rendered route component</span>
|
||||||
|
<strong>{{ title }}</strong>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
7
experiments/router-guard-history/tsconfig.json
Normal file
7
experiments/router-guard-history/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.app.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "../../node_modules/.tmp/router-guard-history.tsbuildinfo"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
|
||||||
|
}
|
||||||
6
experiments/router-guard-history/vite.config.ts
Normal file
6
experiments/router-guard-history/vite.config.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
});
|
||||||
46
external/bugs/composite-transform/README.md
vendored
Normal file
46
external/bugs/composite-transform/README.md
vendored
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# WebKit nested composite-transform frame pacing reproduction
|
||||||
|
|
||||||
|
This is a dependency-free reproduction for an iOS WebKit frame-pacing issue involving two nested animation clocks:
|
||||||
|
|
||||||
|
- A route-sized parent surface whose `translate3d()` progress is updated by `requestAnimationFrame`.
|
||||||
|
- A child with an infinite CSS `rotate()` animation, drawn as a rounded border spinner.
|
||||||
|
|
||||||
|
On the originating device, spinner durations within part of the `0.5s`–`1.1s` range produced regular 33–34 ms rAF gaps, while durations outside that band were smooth. The workload per frame is effectively unchanged, making the duration-dependent behavior unexpected.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
Open `index.html` directly. The reproduction is a single, self-contained HTML file and works from a `file://` URL without a server.
|
||||||
|
|
||||||
|
To test it through a local HTTP server instead, run this from the repository root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npx vite external/bugs/composite-transform
|
||||||
|
```
|
||||||
|
|
||||||
|
This directory is independent of the Vue demo and has no build step or runtime dependencies. The CSS, test scene, controls, and measurement code are all in `index.html`, so that file can be attached directly to a browser bug report.
|
||||||
|
|
||||||
|
## Protocol
|
||||||
|
|
||||||
|
1. Do not scroll or touch the page while a sample is running.
|
||||||
|
2. Run **Sweep 0.5–1.1 s** with **rAF-driven** selected and **Spinner enabled** checked.
|
||||||
|
3. Repeat with **Static translate3d parent**.
|
||||||
|
4. Repeat with **No parent transform**.
|
||||||
|
5. Clear **Spinner enabled** and run the sweep as a control.
|
||||||
|
|
||||||
|
Each duration runs for 1.8 seconds. In the rAF-parent case, the incoming route moves for 700 ms and then remains stationary for the rest of the measurement.
|
||||||
|
|
||||||
|
## Expected
|
||||||
|
|
||||||
|
Changing only `animation-duration` should alter angular velocity, not cause a stable 60 Hz page to begin missing display deadlines. The rAF stream should remain close to 16.7 ms throughout the sweep on a 60 Hz device.
|
||||||
|
|
||||||
|
## Actual observation in the source application
|
||||||
|
|
||||||
|
The problematic sample showed:
|
||||||
|
|
||||||
|
- A 16 ms baseline.
|
||||||
|
- Repeated 33–34 ms gaps during the route transition.
|
||||||
|
- The same gaps continuing while the parent was stationary and the spinner remained visible.
|
||||||
|
- Normal pacing after the spinner was removed from the DOM.
|
||||||
|
- No route-loading delay, JavaScript long task, or layout shift correlated with the gaps.
|
||||||
|
|
||||||
|
The reproduction intentionally contains no Vue, Vue Router, Suspense, application store, network request, or service-worker caching logic in its measured path.
|
||||||
288
external/bugs/composite-transform/index.html
vendored
Normal file
288
external/bugs/composite-transform/index.html
vendored
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Nested transform frame-pacing reproduction</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
font: 15px/1.4 system-ui, sans-serif;
|
||||||
|
background: #0b0d12;
|
||||||
|
color: #f5f7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 { margin-bottom: 8px; font-size: 24px; }
|
||||||
|
p { color: #aeb6ca; }
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 24px 0;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid #30384c;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #151923;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select, button {
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #3a4359;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #202637;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input { width: 110px; }
|
||||||
|
button { cursor: pointer; }
|
||||||
|
button:disabled { cursor: default; opacity: .5; }
|
||||||
|
.buttons { display: flex; gap: 8px; }
|
||||||
|
|
||||||
|
.viewport {
|
||||||
|
position: relative;
|
||||||
|
height: 300px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #30384c;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #11141c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.under, .route {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
padding: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.under {
|
||||||
|
background: linear-gradient(135deg, #171c28, #11141c);
|
||||||
|
color: #8f99b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route {
|
||||||
|
transform: translate3d(100%, 0, 0);
|
||||||
|
background: #202637;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
margin-top: 24px;
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #151923;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
flex: none;
|
||||||
|
border: 3px solid #4b5570;
|
||||||
|
border-top-color: #74a5ff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin var(--spinner-duration, .7s) linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner.off { animation: none; }
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 20px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid #30384c;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:first-child, td:first-child { text-align: left; }
|
||||||
|
.bad { color: #ff9e9e; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Nested composite-transform frame pacing</h1>
|
||||||
|
<p>
|
||||||
|
A CSS border spinner rotates inside a route whose parent transform is driven by
|
||||||
|
<code>requestAnimationFrame</code>. On iOS WebKit, changing only the spinner duration can
|
||||||
|
cause repeatable 33–34 ms frame gaps.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<label>
|
||||||
|
Spinner duration
|
||||||
|
<span><input id="duration" type="number" min="0.1" max="3" step="0.05" value="0.7"> s</span>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Parent transform
|
||||||
|
<select id="parent-mode">
|
||||||
|
<option value="moving">rAF-driven</option>
|
||||||
|
<option value="static">Static translate3d</option>
|
||||||
|
<option value="none">None</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Spinner enabled
|
||||||
|
<input id="spinner-enabled" type="checkbox" checked>
|
||||||
|
</label>
|
||||||
|
<div class="buttons">
|
||||||
|
<button id="run" type="button">Run once</button>
|
||||||
|
<button id="sweep" type="button">Sweep 0.5–1.1 s</button>
|
||||||
|
<button id="stop" type="button" disabled>Stop</button>
|
||||||
|
</div>
|
||||||
|
<output id="status">Ready. Each run measures 1.8 seconds.</output>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="viewport">
|
||||||
|
<div class="under">Existing route</div>
|
||||||
|
<div id="route" class="route">
|
||||||
|
<strong>Incoming route</strong>
|
||||||
|
<div class="card">
|
||||||
|
<span id="spinner" class="spinner"></span>
|
||||||
|
<span>Waiting for asynchronous data…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Spinner</th><th>p95</th><th>Max</th><th>Frames > 30 ms</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="results"><tr><td colspan="4">No runs yet.</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const durations = [.5, .6, .7, .8, .9, 1, 1.1]
|
||||||
|
const sampleTime = 1800
|
||||||
|
const route = document.querySelector('#route')
|
||||||
|
const spinner = document.querySelector('#spinner')
|
||||||
|
const duration = document.querySelector('#duration')
|
||||||
|
const parentMode = document.querySelector('#parent-mode')
|
||||||
|
const spinnerEnabled = document.querySelector('#spinner-enabled')
|
||||||
|
const runButton = document.querySelector('#run')
|
||||||
|
const sweepButton = document.querySelector('#sweep')
|
||||||
|
const stopButton = document.querySelector('#stop')
|
||||||
|
const status = document.querySelector('#status')
|
||||||
|
const results = document.querySelector('#results')
|
||||||
|
let token = 0
|
||||||
|
let rows = []
|
||||||
|
|
||||||
|
const percentile = (values, amount) => {
|
||||||
|
const sorted = [...values].sort((a, b) => a - b)
|
||||||
|
return sorted[Math.floor((sorted.length - 1) * amount)] || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function configure(seconds) {
|
||||||
|
document.documentElement.style.setProperty('--spinner-duration', `${seconds}s`)
|
||||||
|
spinner.classList.toggle('off', !spinnerEnabled.checked)
|
||||||
|
spinner.style.animation = 'none'
|
||||||
|
void spinner.offsetWidth
|
||||||
|
spinner.style.animation = ''
|
||||||
|
route.style.willChange = parentMode.value === 'none' ? 'auto' : 'transform'
|
||||||
|
route.style.transform = parentMode.value === 'moving'
|
||||||
|
? 'translate3d(100%, 0, 0)'
|
||||||
|
: parentMode.value === 'static' ? 'translate3d(0, 0, 0)' : 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
function measure(seconds, currentToken) {
|
||||||
|
configure(seconds)
|
||||||
|
status.value = `Measuring ${seconds.toFixed(2)} s…`
|
||||||
|
|
||||||
|
return new Promise(resolve => {
|
||||||
|
let started
|
||||||
|
let previous
|
||||||
|
const deltas = []
|
||||||
|
|
||||||
|
function frame(now) {
|
||||||
|
if (currentToken !== token) return resolve(null)
|
||||||
|
started ??= now
|
||||||
|
previous ??= now
|
||||||
|
const elapsed = now - started
|
||||||
|
if (elapsed > 0) deltas.push(now - previous)
|
||||||
|
previous = now
|
||||||
|
|
||||||
|
if (parentMode.value === 'moving') {
|
||||||
|
const progress = Math.min(1, elapsed / 700)
|
||||||
|
const eased = 1 - Math.pow(1 - progress, 3)
|
||||||
|
route.style.transform = `translate3d(${(1 - eased) * 100}%, 0, 0)`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elapsed < sampleTime) requestAnimationFrame(frame)
|
||||||
|
else resolve({
|
||||||
|
seconds,
|
||||||
|
p95: percentile(deltas, .95),
|
||||||
|
max: Math.max(...deltas),
|
||||||
|
over30: deltas.filter(value => value > 30).length,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(frame)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
results.innerHTML = rows.map(row => `
|
||||||
|
<tr class="${row.over30 ? 'bad' : ''}">
|
||||||
|
<td>${row.seconds.toFixed(2)} s</td>
|
||||||
|
<td>${row.p95.toFixed(1)} ms</td>
|
||||||
|
<td>${row.max.toFixed(1)} ms</td>
|
||||||
|
<td>${row.over30}</td>
|
||||||
|
</tr>`).join('') || '<tr><td colspan="4">No runs yet.</td></tr>'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(values) {
|
||||||
|
const currentToken = ++token
|
||||||
|
rows = []
|
||||||
|
render()
|
||||||
|
runButton.disabled = sweepButton.disabled = true
|
||||||
|
stopButton.disabled = false
|
||||||
|
|
||||||
|
for (const value of values) {
|
||||||
|
const result = await measure(value, currentToken)
|
||||||
|
if (!result) break
|
||||||
|
rows.push(result)
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentToken === token) status.value = 'Finished.'
|
||||||
|
runButton.disabled = sweepButton.disabled = false
|
||||||
|
stopButton.disabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
runButton.addEventListener('click', () => run([Number(duration.value)]))
|
||||||
|
sweepButton.addEventListener('click', () => run(durations))
|
||||||
|
stopButton.addEventListener('click', () => {
|
||||||
|
token++
|
||||||
|
status.value = 'Stopped.'
|
||||||
|
})
|
||||||
|
duration.addEventListener('input', () => configure(Number(duration.value)))
|
||||||
|
parentMode.addEventListener('change', () => configure(Number(duration.value)))
|
||||||
|
spinnerEnabled.addEventListener('change', () => configure(Number(duration.value)))
|
||||||
|
configure(Number(duration.value))
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
80
package-lock.json
generated
80
package-lock.json
generated
@@ -39,6 +39,7 @@
|
|||||||
"@vue/tsconfig": "^0.9.1",
|
"@vue/tsconfig": "^0.9.1",
|
||||||
"electron": "^43.1.1",
|
"electron": "^43.1.1",
|
||||||
"happy-dom": "^20.10.6",
|
"happy-dom": "^20.10.6",
|
||||||
|
"prettier": "^3.9.6",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"vite": "^8.1.1",
|
"vite": "^8.1.1",
|
||||||
"vitest": "^3.2.4",
|
"vitest": "^3.2.4",
|
||||||
@@ -69,6 +70,21 @@
|
|||||||
"name": "@native-vue-router/demo-electron",
|
"name": "@native-vue-router/demo-electron",
|
||||||
"version": "0.1.0"
|
"version": "0.1.0"
|
||||||
},
|
},
|
||||||
|
"apps/origins-demo": {
|
||||||
|
"name": "@native-vue-router/origins-demo",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@native-vue-router/core-v2": "0.1.0-experimental.0",
|
||||||
|
"vue": "^3.5.39"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^6.0.7",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.1.1",
|
||||||
|
"vite-plugin-pwa": "^1.1.0",
|
||||||
|
"vue-tsc": "^3.3.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@apideck/better-ajv-errors": {
|
"node_modules/@apideck/better-ajv-errors": {
|
||||||
"version": "0.3.7",
|
"version": "0.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.7.tgz",
|
||||||
@@ -1836,6 +1852,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1852,6 +1869,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1868,6 +1886,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1884,6 +1903,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1900,6 +1920,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1916,6 +1937,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1932,6 +1954,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1948,6 +1971,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1964,6 +1988,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1980,6 +2005,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1996,6 +2022,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2012,6 +2039,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2028,6 +2056,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"mips64el"
|
"mips64el"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2044,6 +2073,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2060,6 +2090,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2076,6 +2107,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2092,6 +2124,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2108,6 +2141,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2124,6 +2158,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2140,6 +2175,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2156,6 +2192,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2172,6 +2209,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2188,6 +2226,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2204,6 +2243,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2220,6 +2260,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2236,6 +2277,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2520,6 +2562,10 @@
|
|||||||
"resolved": "packages/core",
|
"resolved": "packages/core",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@native-vue-router/core-v2": {
|
||||||
|
"resolved": "packages/core-v2",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@native-vue-router/demo": {
|
"node_modules/@native-vue-router/demo": {
|
||||||
"resolved": "apps/demo",
|
"resolved": "apps/demo",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -2536,6 +2582,10 @@
|
|||||||
"resolved": "packages/electron",
|
"resolved": "packages/electron",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@native-vue-router/origins-demo": {
|
||||||
|
"resolved": "apps/origins-demo",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@native-vue-router/preset-native": {
|
"node_modules/@native-vue-router/preset-native": {
|
||||||
"resolved": "packages/preset-native",
|
"resolved": "packages/preset-native",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -5084,9 +5134,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-uri": {
|
"node_modules/fast-uri": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
|
||||||
"integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
|
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -6786,6 +6836,22 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/prettier": {
|
||||||
|
"version": "3.9.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
|
||||||
|
"integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"prettier": "bin/prettier.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pretty-bytes": {
|
"node_modules/pretty-bytes": {
|
||||||
"version": "6.1.1",
|
"version": "6.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz",
|
||||||
@@ -9254,6 +9320,14 @@
|
|||||||
"vue-router": "^5.0.0"
|
"vue-router": "^5.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/core-v2": {
|
||||||
|
"name": "@native-vue-router/core-v2",
|
||||||
|
"version": "0.1.0-experimental.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"packages/electron": {
|
"packages/electron": {
|
||||||
"name": "@native-vue-router/electron",
|
"name": "@native-vue-router/electron",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -10,14 +10,17 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host",
|
"dev": "vite --host",
|
||||||
"build": "npm run build:packages && vue-tsc -b && vite build",
|
"build": "npm run build:packages && vue-tsc -b && vite build",
|
||||||
"build:packages": "npm run build --workspace @native-vue-router/core --workspace @native-vue-router/preset-native --workspace @native-vue-router/capacitor --workspace @native-vue-router/electron --if-present",
|
"build:packages": "npm run build --workspace @native-vue-router/core --workspace @native-vue-router/core-v2 --workspace @native-vue-router/preset-native --workspace @native-vue-router/capacitor --workspace @native-vue-router/electron --if-present",
|
||||||
|
"build:v2-demo": "npm run build --workspace @native-vue-router/origins-demo",
|
||||||
|
"dev:v2": "npm run dev --workspace @native-vue-router/origins-demo",
|
||||||
|
"serve:v2": "npm run build:v2-demo && npm run preview --workspace @native-vue-router/origins-demo",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"pwa:preview": "npm run build && vite preview --host 0.0.0.0 --port 4173",
|
|
||||||
"electron": "npm run build && electron apps/electron/main.mjs",
|
"electron": "npm run build && electron apps/electron/main.mjs",
|
||||||
"cap:sync": "npm run build && npm --prefix apps/capacitor exec cap sync"
|
"cap:sync": "npm run build && npm --prefix apps/capacitor exec cap sync",
|
||||||
|
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,css,md}\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/app": "^8.0.0",
|
"@capacitor/app": "^8.0.0",
|
||||||
@@ -47,6 +50,7 @@
|
|||||||
"@vue/tsconfig": "^0.9.1",
|
"@vue/tsconfig": "^0.9.1",
|
||||||
"electron": "^43.1.1",
|
"electron": "^43.1.1",
|
||||||
"happy-dom": "^20.10.6",
|
"happy-dom": "^20.10.6",
|
||||||
|
"prettier": "^3.9.6",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"vite": "^8.1.1",
|
"vite": "^8.1.1",
|
||||||
"vitest": "^3.2.4",
|
"vitest": "^3.2.4",
|
||||||
|
|||||||
@@ -3,9 +3,18 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"files": ["dist"],
|
"files": [
|
||||||
"scripts": { "build": "vite build --config vite.config.ts && vue-tsc -p tsconfig.json --emitDeclarationOnly" },
|
"dist"
|
||||||
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "vite build --config vite.config.ts && vue-tsc -p tsconfig.json --emitDeclarationOnly"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@capacitor/app": "^8.0.0",
|
"@capacitor/app": "^8.0.0",
|
||||||
"@capacitor/core": "^8.0.0",
|
"@capacitor/core": "^8.0.0",
|
||||||
|
|||||||
@@ -1,45 +1,66 @@
|
|||||||
import { App } from '@capacitor/app'
|
import { App } from "@capacitor/app";
|
||||||
import { Capacitor } from '@capacitor/core'
|
import { Capacitor } from "@capacitor/core";
|
||||||
import { Haptics, ImpactStyle } from '@capacitor/haptics'
|
import { Haptics, ImpactStyle } from "@capacitor/haptics";
|
||||||
import type { NativePlatformAdapter, NativeRouterRuntime } from '@native-vue-router/core'
|
import type {
|
||||||
|
NativePlatformAdapter,
|
||||||
|
NativeRouterRuntime,
|
||||||
|
} from "@native-vue-router/core";
|
||||||
|
|
||||||
export interface CapacitorAdapterOptions {
|
export interface CapacitorAdapterOptions {
|
||||||
exitAtRoot?: boolean
|
exitAtRoot?: boolean;
|
||||||
haptics?: boolean
|
haptics?: boolean;
|
||||||
deepLinkPath?: (url: URL) => string
|
/** Release inactive component trees when the native app backgrounds. Defaults to true. */
|
||||||
|
trimCacheOnPause?: boolean;
|
||||||
|
deepLinkPath?: (url: URL) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCapacitorAdapter(options: CapacitorAdapterOptions = {}): NativePlatformAdapter {
|
export function createCapacitorAdapter(
|
||||||
const enabled = Capacitor.isNativePlatform()
|
options: CapacitorAdapterOptions = {},
|
||||||
|
): NativePlatformAdapter {
|
||||||
|
const enabled = Capacitor.isNativePlatform();
|
||||||
return {
|
return {
|
||||||
name: enabled ? `capacitor-${Capacitor.getPlatform()}` : 'capacitor-web',
|
name: enabled ? `capacitor-${Capacitor.getPlatform()}` : "capacitor-web",
|
||||||
async haptic(event) {
|
async haptic(event) {
|
||||||
if (!enabled || options.haptics === false) return
|
if (!enabled || options.haptics === false) return;
|
||||||
if (event === 'selection') await Haptics.selectionChanged()
|
if (event === "selection") await Haptics.selectionChanged();
|
||||||
else await Haptics.impact({ style: event === 'commit' ? ImpactStyle.Light : ImpactStyle.Medium })
|
else
|
||||||
|
await Haptics.impact({
|
||||||
|
style: event === "commit" ? ImpactStyle.Light : ImpactStyle.Medium,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
async install(runtime: NativeRouterRuntime) {
|
async install(runtime: NativeRouterRuntime) {
|
||||||
if (!enabled) return
|
if (!enabled) return;
|
||||||
const handles = await Promise.all([
|
const handles = await Promise.all([
|
||||||
App.addListener('backButton', async () => {
|
App.addListener("backButton", async () => {
|
||||||
if (runtime.transaction.value) return await runtime.cancelInteractive()
|
if (runtime.transaction.value)
|
||||||
if (runtime.canGoBack.value) return void await runtime.pop()
|
return await runtime.cancelInteractive();
|
||||||
if (options.exitAtRoot !== false) await App.exitApp()
|
if (runtime.canGoBack.value) return void (await runtime.pop());
|
||||||
|
if (options.exitAtRoot !== false) await App.exitApp();
|
||||||
}),
|
}),
|
||||||
App.addListener('appUrlOpen', ({ url }) => {
|
App.addListener("appUrlOpen", ({ url }) => {
|
||||||
const parsed = new URL(url)
|
const parsed = new URL(url);
|
||||||
const path = options.deepLinkPath?.(parsed) ?? `${parsed.pathname}${parsed.search}${parsed.hash}`
|
const path =
|
||||||
void runtime.push(path || '/')
|
options.deepLinkPath?.(parsed) ??
|
||||||
|
`${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||||
|
void runtime.push(path || "/");
|
||||||
}),
|
}),
|
||||||
App.addListener('pause', () => void runtime.cancelInteractive()),
|
App.addListener("pause", () => {
|
||||||
])
|
void runtime.cancelInteractive();
|
||||||
const launch = await App.getLaunchUrl()
|
if (options.trimCacheOnPause !== false)
|
||||||
|
runtime.trimCache({ reason: "memory-pressure" });
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const launch = await App.getLaunchUrl();
|
||||||
if (launch?.url) {
|
if (launch?.url) {
|
||||||
const parsed = new URL(launch.url)
|
const parsed = new URL(launch.url);
|
||||||
const path = options.deepLinkPath?.(parsed) ?? `${parsed.pathname}${parsed.search}${parsed.hash}`
|
const path =
|
||||||
if (path) void runtime.replace(path, { presentation: 'none' })
|
options.deepLinkPath?.(parsed) ??
|
||||||
|
`${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||||
|
if (path) void runtime.replace(path, { presentation: "none" });
|
||||||
}
|
}
|
||||||
return () => { void Promise.all(handles.map((handle) => handle.remove())) }
|
return () => {
|
||||||
|
void Promise.all(handles.map((handle) => handle.remove()));
|
||||||
|
};
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from "vite";
|
||||||
import { resolve } from 'node:path'
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
build: {
|
build: {
|
||||||
outDir: 'dist', emptyOutDir: true,
|
outDir: "dist",
|
||||||
lib: { entry: resolve(__dirname, 'src/index.ts'), formats: ['es'], fileName: 'index' },
|
emptyOutDir: true,
|
||||||
rollupOptions: { external: ['@capacitor/app', '@capacitor/core', '@capacitor/haptics', '@native-vue-router/core'] },
|
lib: {
|
||||||
|
entry: resolve(__dirname, "src/index.ts"),
|
||||||
|
formats: ["es"],
|
||||||
|
fileName: "index",
|
||||||
},
|
},
|
||||||
})
|
rollupOptions: {
|
||||||
|
external: [
|
||||||
|
"@capacitor/app",
|
||||||
|
"@capacitor/core",
|
||||||
|
"@capacitor/haptics",
|
||||||
|
"@native-vue-router/core",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
777
packages/core-v2/API.md
Normal file
777
packages/core-v2/API.md
Normal file
@@ -0,0 +1,777 @@
|
|||||||
|
# Core v2 API reference
|
||||||
|
|
||||||
|
`@native-vue-router/core-v2` is a routeless Vue scene compositor. A mounted
|
||||||
|
component can create another component, animate both through a local operation
|
||||||
|
frame, and retain either side when the operation resolves.
|
||||||
|
|
||||||
|
This document describes the experimental `0.1.0-experimental.0` API.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- [Gesture start recognition](#gesture-start-recognition)
|
||||||
|
- [Minimum setup](#minimum-setup)
|
||||||
|
- [Components](#components)
|
||||||
|
- [Nested scenes](#nested-scenes)
|
||||||
|
- [Scene and view functions](#scene-and-view-functions)
|
||||||
|
- [Actions and history](#actions-and-history)
|
||||||
|
- [Gestures](#gestures)
|
||||||
|
- [Choreographies and effects](#choreographies-and-effects)
|
||||||
|
- [Node-scoped controls](#node-scoped-controls)
|
||||||
|
- [Scene diagnostics and manual operations](#scene-diagnostics-and-manual-operations)
|
||||||
|
- [Type reference](#type-reference)
|
||||||
|
- [Errors and constraints](#errors-and-constraints)
|
||||||
|
|
||||||
|
## Gesture start recognition
|
||||||
|
|
||||||
|
The builder separates where a gesture begins from the direction it moves:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const edgeBack = gesture.from
|
||||||
|
.left("clamp(24px, 7vw, 48px)")
|
||||||
|
.to.right()
|
||||||
|
.navigate((context) => (context.canGoBack ? back() : null))
|
||||||
|
.animate(slideRight);
|
||||||
|
```
|
||||||
|
|
||||||
|
Edges are measured from the **gesture host element**, not unconditionally from
|
||||||
|
the browser viewport. Supported start rules are:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
gesture.from.left(distance);
|
||||||
|
gesture.from.right(distance);
|
||||||
|
gesture.from.top(distance);
|
||||||
|
gesture.from.bottom(distance);
|
||||||
|
gesture.from.anywhere();
|
||||||
|
gesture.from.when(predicate);
|
||||||
|
```
|
||||||
|
|
||||||
|
`distance` accepts a number in CSS pixels or a CSS length string, including
|
||||||
|
percentages, `calc()`, and `clamp()`. It is resolved against the current host
|
||||||
|
size at pointer-down.
|
||||||
|
|
||||||
|
`.from` is optional. A chain beginning at `.to` admits pointer-down anywhere:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
gesture.to.right();
|
||||||
|
```
|
||||||
|
|
||||||
|
This is semantically equivalent to:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
gesture.from.anywhere().to.right();
|
||||||
|
```
|
||||||
|
|
||||||
|
It does not capture on the first positive pixel. The recognizer waits until
|
||||||
|
directed movement crosses the intent threshold and dominates the cross-axis.
|
||||||
|
|
||||||
|
Custom shapes, safe-area rules, and exclusion zones belong in `.from.when()`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const dropDialog = gesture.from
|
||||||
|
.when(({ point, bounds, event }) => {
|
||||||
|
const rail = Math.max(36, bounds.width * 0.08);
|
||||||
|
const outsideExcludedBand =
|
||||||
|
event.clientY < bounds.top + bounds.height * 0.35 ||
|
||||||
|
event.clientY > bounds.top + bounds.height * 0.65;
|
||||||
|
return point.localX <= rail && outsideExcludedBand;
|
||||||
|
})
|
||||||
|
.to.down()
|
||||||
|
.navigate(() => above(originView(DialogView)))
|
||||||
|
.animate(dropAnimation);
|
||||||
|
```
|
||||||
|
|
||||||
|
Interactive form controls are ignored automatically. Add
|
||||||
|
`data-origin-gesture="ignore"` to any other element or ancestor that should not
|
||||||
|
begin a gesture.
|
||||||
|
|
||||||
|
## Minimum setup
|
||||||
|
|
||||||
|
Import the required compositor stylesheet once:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import "@native-vue-router/core-v2/style.css";
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a scene:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createOriginScene, originView } from "@native-vue-router/core-v2";
|
||||||
|
import HomeView from "./HomeView.vue";
|
||||||
|
|
||||||
|
export const scene = createOriginScene({
|
||||||
|
initial: originView(HomeView, undefined, {
|
||||||
|
key: "home",
|
||||||
|
name: "Home",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Render it:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { OriginScene } from "@native-vue-router/core-v2";
|
||||||
|
import { scene } from "./scene";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<OriginScene :scene="scene" />
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
`OriginScene` must have a non-zero width and height through its parent layout.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### `OriginScene`
|
||||||
|
|
||||||
|
Renders every currently mounted scene node as a stable, absolutely positioned
|
||||||
|
sibling.
|
||||||
|
|
||||||
|
| Prop | Type | Required | Description |
|
||||||
|
| ------- | ------------- | -------- | -------------------------------------- |
|
||||||
|
| `scene` | `OriginScene` | yes | Scene created by `createOriginScene()` |
|
||||||
|
|
||||||
|
The component provides node ownership to descendants, registers host elements
|
||||||
|
for measurement, and applies the scene's composited styles. Application views
|
||||||
|
must be rendered through this component before calling `useOrigin()` or
|
||||||
|
`useOriginGesture()`.
|
||||||
|
|
||||||
|
### `OriginGesture`
|
||||||
|
|
||||||
|
Convenience component that renders one HTML element and binds one gesture
|
||||||
|
recognizer to it.
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
| --------- | ------------------------- | ------- | ---------------------------------- |
|
||||||
|
| `as` | `string` | `"div"` | HTML tag used for the gesture host |
|
||||||
|
| `gesture` | `OriginGestureDefinition` | — | Preferred builder definition |
|
||||||
|
|
||||||
|
Attributes, classes, and listeners not consumed as props are forwarded to the
|
||||||
|
rendered host.
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<OriginGesture as="main" class="profile" :gesture="openDetailsGesture">
|
||||||
|
...
|
||||||
|
</OriginGesture>
|
||||||
|
```
|
||||||
|
|
||||||
|
For compatibility, the component also accepts the legacy mutually exclusive
|
||||||
|
set of `direction`, `edge`, `threshold`, and `action` props.
|
||||||
|
|
||||||
|
Use `useOriginGesture()` instead when an extra wrapper is undesirable.
|
||||||
|
|
||||||
|
### `OriginGestureSurface`
|
||||||
|
|
||||||
|
Policy-neutral host for multiple completed builder definitions:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
const gestures = [forwardGesture, backGesture] as const;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<OriginGestureSurface as="main" :gestures="gestures">
|
||||||
|
...
|
||||||
|
</OriginGestureSurface>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
| ---------- | ------------------------------------ | -------- | ------------------------------ |
|
||||||
|
| `as` | `string` | `"div"` | Shared native gesture host |
|
||||||
|
| `gestures` | `readonly OriginGestureDefinition[]` | required | Fully defined page-owned rules |
|
||||||
|
|
||||||
|
The component adds no recognition or navigation policy. It installs each
|
||||||
|
definition with `useOriginGesture()`, forwards every pointer event to every
|
||||||
|
binding, and derives the least-permissive shared `touch-action`:
|
||||||
|
|
||||||
|
- horizontal only: `pan-y`;
|
||||||
|
- vertical only: `pan-x`;
|
||||||
|
- both axes: `none`.
|
||||||
|
|
||||||
|
Definitions should be immutable and stable for the lifetime of the rendered
|
||||||
|
surface. The owning page remains the visible declaration point for every
|
||||||
|
`.from`, `.to`, `.complete`, `.navigate`, and `.animate` choice.
|
||||||
|
|
||||||
|
## Nested scenes
|
||||||
|
|
||||||
|
`OriginScene` is a reusable compositor, not an application-only singleton. A
|
||||||
|
component may render another scene inside its own layout:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<section class="carousel">
|
||||||
|
<OriginScene :scene="carouselScene" />
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
The child scene gets independent mounted nodes, retained history, measurements,
|
||||||
|
operations, and clipping. `useOrigin()` and `useOriginGesture()` resolve the
|
||||||
|
nearest scene-node provider, so declarations inside a carousel slide operate
|
||||||
|
on carousel components rather than the outer page.
|
||||||
|
|
||||||
|
Parent/child gesture arbitration is currently pointer-down based. An eligible
|
||||||
|
child recognizer stops propagation immediately. If its later `.navigate()`
|
||||||
|
factory returns `null`, that same pointer sequence is not offered to the
|
||||||
|
parent. Cooperative nested components should reserve a start region with
|
||||||
|
`.from.when()` or `.from.left()` that allows the parent handler to receive
|
||||||
|
pointer-down.
|
||||||
|
|
||||||
|
The nested-scenes demo contains both this cooperative policy and an intentional
|
||||||
|
greedy-child conflict. A future gesture arena could delay ownership until
|
||||||
|
direction and navigation availability are known.
|
||||||
|
|
||||||
|
## Scene and view functions
|
||||||
|
|
||||||
|
### `originView(component, props?, options?)`
|
||||||
|
|
||||||
|
Creates a lightweight recipe for mounting a Vue component.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const profile = originView(
|
||||||
|
ProfileView,
|
||||||
|
{ userId: "42" },
|
||||||
|
{ key: "profile-42", name: "Profile" },
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
The recipe is not itself a mounted instance. A forward action creates an
|
||||||
|
instance from it, then retains that exact instance while its entry remains in
|
||||||
|
history. Back does not call the recipe again. Component definitions are marked
|
||||||
|
raw so Vue does not proxy them inside reactive scene structures.
|
||||||
|
|
||||||
|
`OriginViewOptions`:
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ------ | -------- | --------------------------------------------- |
|
||||||
|
| `key` | `string` | Recipe identity and generated node-key prefix |
|
||||||
|
| `name` | `string` | Human-readable diagnostic label |
|
||||||
|
|
||||||
|
When no key is supplied, one is generated from the component/name and a
|
||||||
|
sequence number.
|
||||||
|
|
||||||
|
### `createOriginScene(options)`
|
||||||
|
|
||||||
|
Creates one independent scene graph, history context, and compositor.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const scene = createOriginScene({
|
||||||
|
initial: originView(HomeView),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
`options.initial` accepts one `OriginView` or an array of independent root
|
||||||
|
views. Scenes do not share nodes, history, operation IDs, or measurements.
|
||||||
|
|
||||||
|
## Actions and history
|
||||||
|
|
||||||
|
### `forward(target, choreography?, options?)`
|
||||||
|
|
||||||
|
With choreography, creates a complete retained-history push action:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const openProfile = () =>
|
||||||
|
forward(originView(ProfileView, { userId: "42" }), slideLeft);
|
||||||
|
```
|
||||||
|
|
||||||
|
Without choreography, it creates an `OriginNavigationIntent` for a gesture
|
||||||
|
builder:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
.navigate(() => forward(originView(ProfileView, { userId: "42" })))
|
||||||
|
```
|
||||||
|
|
||||||
|
When forward commits, the origin remains mounted but becomes parked. Its DOM,
|
||||||
|
component-local state, and nested scroll positions remain intact.
|
||||||
|
|
||||||
|
`OriginNavigationActionOptions.placement` defaults to `"above"`.
|
||||||
|
|
||||||
|
### `back(choreography?, options?)`
|
||||||
|
|
||||||
|
Creates a retained-history pop action:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const goBack = (context: OriginContext) =>
|
||||||
|
context.canGoBack ? back(slideRight) : null;
|
||||||
|
```
|
||||||
|
|
||||||
|
Back has no target recipe. When it begins, the scene resolves the origin's
|
||||||
|
`previousNodeKey` and reveals that exact mounted instance. A committed back
|
||||||
|
unmounts only the current entry. A cancelled back hides the previous entry
|
||||||
|
again and leaves the current entry active.
|
||||||
|
|
||||||
|
`back()` without choreography returns an animation-free navigation intent for
|
||||||
|
`.navigate()`. `back(slideRight)` returns a complete programmatic action.
|
||||||
|
|
||||||
|
`OriginNavigationActionOptions.placement` defaults to `"under"`.
|
||||||
|
|
||||||
|
### `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()`, `replace()`,
|
||||||
|
and `back()` when expressing retained navigation.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const action = originAction(profile, slideLeft, {
|
||||||
|
placement: "above",
|
||||||
|
history: "push",
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
`OriginActionOptions`:
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
| ----------- | -------------------- | --------- | ---------------------------- |
|
||||||
|
| `placement` | `"above" \| "under"` | `"above"` | Target stacking relationship |
|
||||||
|
| `history` | `OriginHistoryMode` | `"push"` | Target history mutation |
|
||||||
|
|
||||||
|
### `above(target, choreography?, options?)`
|
||||||
|
|
||||||
|
Shorthand for `originAction()` with `placement: "above"`.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const openProfile = () => above(originView(ProfileView), slideLeft);
|
||||||
|
```
|
||||||
|
|
||||||
|
Placement controls stacking only. It does not imply a movement direction.
|
||||||
|
Omitting choreography returns a navigation intent for a gesture builder.
|
||||||
|
|
||||||
|
### `under(target, choreography?, options?)`
|
||||||
|
|
||||||
|
Shorthand for `originAction()` with `placement: "under"`.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const goBack = (context: OriginContext) =>
|
||||||
|
context.previous ? back(slideRight) : null;
|
||||||
|
```
|
||||||
|
|
||||||
|
`under()` does not automatically mean history back. It remains available for
|
||||||
|
custom stacking actions; `back()` is the clearer retained-history primitive.
|
||||||
|
Omitting choreography returns a navigation intent for a gesture builder.
|
||||||
|
|
||||||
|
### History modes
|
||||||
|
|
||||||
|
History is a linked chain of mounted scene nodes.
|
||||||
|
|
||||||
|
| Mode | Commit behavior |
|
||||||
|
| ----------- | -------------------------------------------------------------- |
|
||||||
|
| `"push"` | Park and retain the origin; activate the new target |
|
||||||
|
| `"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.
|
||||||
|
|
||||||
|
## Gestures
|
||||||
|
|
||||||
|
### `gesture`
|
||||||
|
|
||||||
|
Immutable fluent builder for component-owned gesture policy:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const swipeBack = gesture.from
|
||||||
|
.left(32)
|
||||||
|
.to.right({ threshold: 10 })
|
||||||
|
.complete(({ progress, velocity }) => progress >= 0.4 || velocity >= 0.9)
|
||||||
|
.navigate((context) => (context.canGoBack ? back() : null))
|
||||||
|
.animate(slideRight);
|
||||||
|
```
|
||||||
|
|
||||||
|
The stages have distinct responsibilities:
|
||||||
|
|
||||||
|
| Stage | Responsibility |
|
||||||
|
| ---------------------- | ---------------------------------------------------------- |
|
||||||
|
| `.from.*` | Optional pointer-down eligibility |
|
||||||
|
| `.to.*` | Required movement direction and intent-recognition options |
|
||||||
|
| `.complete(predicate)` | Optional release commit/cancel decision |
|
||||||
|
| `.navigate(factory)` | Required target and retained-history intent |
|
||||||
|
| `.animate(routine)` | Required source/target/frame choreography |
|
||||||
|
|
||||||
|
The builder is persistent and immutable. Reusing an earlier stage cannot
|
||||||
|
change a definition already produced from it.
|
||||||
|
|
||||||
|
`.to.left()`, `.to.right()`, `.to.up()`, and `.to.down()` accept optional
|
||||||
|
`OriginGestureDirectionOptions`:
|
||||||
|
|
||||||
|
| Field | Default | Description |
|
||||||
|
| --------------- | ------- | ------------------------------------------------- |
|
||||||
|
| `threshold` | `8` | Directed CSS pixels required before capture |
|
||||||
|
| `axisDominance` | `1.15` | Directed/cross-axis ratio required before capture |
|
||||||
|
|
||||||
|
If `.complete()` is omitted, the choreography's `commitThreshold` and
|
||||||
|
`commitVelocity` decide release normally.
|
||||||
|
|
||||||
|
The completion context contains the origin, direction, normalized progress and
|
||||||
|
velocity, directed pixel distance, cross-axis distance, duration, pointer-up
|
||||||
|
event, host, bounds, and start/current points. Completion predicates are
|
||||||
|
synchronous because they select operation intent at release.
|
||||||
|
|
||||||
|
### `useOriginGesture(definition)`
|
||||||
|
|
||||||
|
Creates one primary-pointer, single-axis recognizer owned by the component that
|
||||||
|
calls it.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const open = useOriginGesture(
|
||||||
|
gesture.to
|
||||||
|
.left({ threshold: 10 })
|
||||||
|
.navigate(() => forward(originView(DetailsView)))
|
||||||
|
.animate(slideLeft),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
The return value contains:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface OriginGestureBinding {
|
||||||
|
readonly style: Readonly<CSSProperties>;
|
||||||
|
readonly onPointerdown: (event: PointerEvent) => void;
|
||||||
|
readonly onPointermove: (event: PointerEvent) => void;
|
||||||
|
readonly onPointerup: (event: PointerEvent) => void;
|
||||||
|
readonly onPointercancel: () => void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply all handlers to the same element. The returned style sets dimensions and
|
||||||
|
`touch-action` so native scrolling remains available on the cross-axis.
|
||||||
|
|
||||||
|
Recognition requires:
|
||||||
|
|
||||||
|
1. A primary, left-button pointer satisfies the optional start policy.
|
||||||
|
2. The target is not an ignored interactive element.
|
||||||
|
3. Directed movement reaches `threshold`.
|
||||||
|
4. Directed movement exceeds cross-axis movement by `axisDominance`.
|
||||||
|
5. The navigation factory returns an intent.
|
||||||
|
|
||||||
|
Progress is directed distance divided by host width or height. Release velocity
|
||||||
|
is normalized by the same dimension.
|
||||||
|
|
||||||
|
An asynchronous navigation factory is supported. If it resolves after the
|
||||||
|
pointer was released or cancelled, the stale result is discarded.
|
||||||
|
|
||||||
|
The legacy `OriginGestureOptions` object remains accepted. Its `edge` is a
|
||||||
|
number inferred from the side opposite `direction`, matching the previous API.
|
||||||
|
|
||||||
|
## Choreographies and effects
|
||||||
|
|
||||||
|
### `defineOriginChoreography(choreography)`
|
||||||
|
|
||||||
|
Type-safe identity helper for declaring custom visual routines.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const scaleIn = defineOriginChoreography({
|
||||||
|
name: "scale-in",
|
||||||
|
commitThreshold: 0.4,
|
||||||
|
commitVelocity: 0.8,
|
||||||
|
effects: ({ progress, viewport }) => ({
|
||||||
|
source: {
|
||||||
|
transform: `scale(${1 - progress * 0.08})`,
|
||||||
|
opacity: 1 - progress * 0.3,
|
||||||
|
},
|
||||||
|
target: {
|
||||||
|
transform: `translateY(${(1 - progress) * viewport.height}px)`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The function returns the same object. Its value is type checking and a clear
|
||||||
|
construction point.
|
||||||
|
|
||||||
|
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 |
|
||||||
|
| -------- | ---------------------------------------------------------- |
|
||||||
|
| `frame` | Source, target, and descendants on both sides of this edge |
|
||||||
|
| `source` | Component that originated this operation |
|
||||||
|
| `target` | Component created by this operation and its descendants |
|
||||||
|
|
||||||
|
Transforms are concatenated from inherited frames to local frames. Opacity is
|
||||||
|
multiplied. Properties inside `style` use local-last precedence, except
|
||||||
|
`transform` and numeric `opacity`, which are also composed.
|
||||||
|
|
||||||
|
Choreography callbacks should be deterministic and free of side effects. They
|
||||||
|
can run repeatedly during rendering and animation.
|
||||||
|
|
||||||
|
### Commit thresholds
|
||||||
|
|
||||||
|
When `finish()` does not explicitly override the decision, a target commits
|
||||||
|
when either:
|
||||||
|
|
||||||
|
- `progress >= commitThreshold`, default `0.36`; or
|
||||||
|
- `progress >= 0.06` and `velocity >= commitVelocity`, default `0.9`.
|
||||||
|
|
||||||
|
The operation's intent becomes final before its spring settles.
|
||||||
|
|
||||||
|
### Included presets
|
||||||
|
|
||||||
|
| Export | Behavior |
|
||||||
|
| ------------ | ------------------------------------------------------------- |
|
||||||
|
| `slideLeft` | Target enters from the right above a slightly receding source |
|
||||||
|
| `slideRight` | Source exits right and reveals a target underneath |
|
||||||
|
| `fade` | Source fades out as target fades in |
|
||||||
|
|
||||||
|
These are ordinary `OriginChoreography` objects and can be replaced entirely.
|
||||||
|
|
||||||
|
### `normalizedEffect(effect, fallbackLayer?)`
|
||||||
|
|
||||||
|
Internal compositor helper exposed for custom diagnostics or compositors. It
|
||||||
|
returns a defined effect and adds `fallbackLayer` to the effect's own layer.
|
||||||
|
Applications normally return plain effects and let the scene normalize them.
|
||||||
|
|
||||||
|
## Node-scoped controls
|
||||||
|
|
||||||
|
### `useOrigin()`
|
||||||
|
|
||||||
|
Returns controls scoped to the scene node containing the calling component.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const origin = useOrigin();
|
||||||
|
|
||||||
|
await origin.perform(forward(originView(SettingsView), fade));
|
||||||
|
```
|
||||||
|
|
||||||
|
Return value:
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
| ----------------- | --------------------------------------------------- |
|
||||||
|
| `nodeKey` | Unique key of this mounted node |
|
||||||
|
| `scene` | Containing `OriginScene` |
|
||||||
|
| `context` | Reactive node-local `OriginContext` |
|
||||||
|
| `view` | Reactive shorthand for the current recipe |
|
||||||
|
| `previous` | Recipe belonging to the retained previous instance |
|
||||||
|
| `canGoBack` | Whether a retained previous instance exists |
|
||||||
|
| `begin(action)` | Create a target and return manual operation control |
|
||||||
|
| `perform(action)` | Create and programmatically commit a target |
|
||||||
|
|
||||||
|
The composable throws when called outside a view mounted by `OriginScene`.
|
||||||
|
There is no global `activeView`; the injected node containing the event is the
|
||||||
|
origin.
|
||||||
|
|
||||||
|
## Scene diagnostics and manual operations
|
||||||
|
|
||||||
|
### `OriginScene` fields
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ------------ | ----------------------------------------- | ------------------------------------- |
|
||||||
|
| `nodes` | `ComputedRef<readonly OriginSceneNode[]>` | Currently mounted Vue component nodes |
|
||||||
|
| `operations` | `ComputedRef<readonly OriginOperation[]>` | Live operation edges |
|
||||||
|
| `roots` | `ShallowRef<readonly string[]>` | Visible operation-graph roots |
|
||||||
|
|
||||||
|
These fields are suitable for inspectors and diagnostics. Do not mutate their
|
||||||
|
contents.
|
||||||
|
|
||||||
|
### `scene.contextFor(nodeKey)`
|
||||||
|
|
||||||
|
Returns the `OriginContext` for a mounted node. Throws if the key no longer
|
||||||
|
exists.
|
||||||
|
|
||||||
|
### `scene.begin(originKey, action)`
|
||||||
|
|
||||||
|
For forward 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);
|
||||||
|
handle.update(0.25, 0.4);
|
||||||
|
const committed = await handle.finish();
|
||||||
|
```
|
||||||
|
|
||||||
|
Only one outgoing operation may exist for a given origin. Its created target
|
||||||
|
can immediately begin its own outgoing operation, enabling X → Y → Z chains.
|
||||||
|
|
||||||
|
### `OriginOperationHandle`
|
||||||
|
|
||||||
|
| Member | Description |
|
||||||
|
| ----------------------------- | --------------------------------------------------- |
|
||||||
|
| `id` | Unique operation ID |
|
||||||
|
| `originKey` | Source node key |
|
||||||
|
| `targetKey` | Created or retained target node key |
|
||||||
|
| `update(progress, velocity?)` | Update normalized interactive state |
|
||||||
|
| `finish(options?)` | Decide, settle, and return whether target committed |
|
||||||
|
| `cancel(options?)` | Force cancellation and remove the target branch |
|
||||||
|
|
||||||
|
`OriginFinishOptions`:
|
||||||
|
|
||||||
|
| Field | Default | Description |
|
||||||
|
| --------- | ------------------ | ---------------------------- |
|
||||||
|
| `commit` | threshold decision | Force commit or cancellation |
|
||||||
|
| `animate` | `true` | Run the settling spring |
|
||||||
|
|
||||||
|
### `scene.perform(originKey, action)`
|
||||||
|
|
||||||
|
Equivalent to beginning an operation and immediately finishing it with
|
||||||
|
`commit: true`. The target still uses the settling spring unless reduced motion
|
||||||
|
is active.
|
||||||
|
|
||||||
|
### Renderer integration methods
|
||||||
|
|
||||||
|
`registerElement()`, `registerContainer()`, `styleForNode()`, and
|
||||||
|
`isNodeInteractive()` are public at the TypeScript boundary because the Vue
|
||||||
|
renderer components consume them. They are internal integration APIs and may
|
||||||
|
change during the experimental series.
|
||||||
|
|
||||||
|
## Type reference
|
||||||
|
|
||||||
|
### `OriginView<Props>`
|
||||||
|
|
||||||
|
A component recipe containing `component`, optional `props`, optional `key`,
|
||||||
|
and optional diagnostic `name`.
|
||||||
|
|
||||||
|
### `OriginAction`
|
||||||
|
|
||||||
|
A choreography, placement, history mode, and—except for back—target recipe.
|
||||||
|
|
||||||
|
### `OriginNavigationIntent`
|
||||||
|
|
||||||
|
An animation-free target, placement, and history mutation returned by
|
||||||
|
`forward()`, `replace()`, `back()`, `above()`, or `under()` when choreography
|
||||||
|
is omitted. Gesture `.animate()` combines it with choreography to create the
|
||||||
|
internal action.
|
||||||
|
|
||||||
|
### Gesture definition types
|
||||||
|
|
||||||
|
- `OriginGestureDefinition`: immutable executable result passed to
|
||||||
|
`useOriginGesture()` or the `OriginGesture` component.
|
||||||
|
- `OriginGestureStart`: anywhere, edge, or predicate start policy.
|
||||||
|
- `OriginGestureDistance`: numeric CSS pixels or a CSS length string.
|
||||||
|
- `OriginGestureStartContext`: pointer-down event, origin, host, bounds, and
|
||||||
|
local/client point.
|
||||||
|
- `OriginGestureCompletionContext`: release metrics and origin/DOM context.
|
||||||
|
- `OriginGestureDirectionOptions`: `threshold` and `axisDominance`.
|
||||||
|
- `OriginGestureBinding`: host style and four pointer handlers.
|
||||||
|
- `OriginGestureSurfaceProps`: shared host tag and completed definition list.
|
||||||
|
- `MaybeOriginNavigationIntent`: synchronous or asynchronous nullable
|
||||||
|
navigation-factory result.
|
||||||
|
|
||||||
|
### `OriginContext`
|
||||||
|
|
||||||
|
Node-local action context:
|
||||||
|
|
||||||
|
- `nodeKey`: mounted origin identity.
|
||||||
|
- `view`: origin recipe.
|
||||||
|
- `canGoBack`: whether a retained previous instance exists.
|
||||||
|
- `previous`: recipe belonging to the mounted previous entry.
|
||||||
|
- `history`: recipes belonging to all retained previous entries.
|
||||||
|
|
||||||
|
### `MaybeOriginAction`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
OriginAction | null | undefined | Promise<OriginAction | null | undefined>;
|
||||||
|
```
|
||||||
|
|
||||||
|
### `OriginEffect`
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
| ----------- | ----------------------------------------------- |
|
||||||
|
| `transform` | Composable CSS transform contribution |
|
||||||
|
| `opacity` | Multiplicative opacity contribution |
|
||||||
|
| `layer` | Relative stacking contribution |
|
||||||
|
| `style` | Other CSS properties with local-last precedence |
|
||||||
|
|
||||||
|
`above()` adds a default target layer of `+1`; `under()` adds `-1`.
|
||||||
|
|
||||||
|
### `OriginChoreographyContext`
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
| ------------ | ------------------------------------------------------ |
|
||||||
|
| `progress` | Normalized `0..1` progress |
|
||||||
|
| `velocity` | Normalized progress units per second |
|
||||||
|
| `phase` | `preparing`, `interactive`, `settling`, or `finished` |
|
||||||
|
| `intent` | `undecided`, `commit`, or `cancel` |
|
||||||
|
| `originRect` | Origin bounds captured before target mounting |
|
||||||
|
| `targetRect` | Target bounds measured after mounting |
|
||||||
|
| `viewport` | Scene-container bounds, with browser viewport fallback |
|
||||||
|
|
||||||
|
Rect values are viewport CSS pixels.
|
||||||
|
|
||||||
|
### Diagnostic types
|
||||||
|
|
||||||
|
- `OriginSceneNode`: mounted identity, retained previous key, state, recipe,
|
||||||
|
history, and incoming edge.
|
||||||
|
- `OriginSceneNodeState`: `active`, `transitioning`, `exposed`, or `parked`.
|
||||||
|
- `OriginOperation`: read-only live edge state.
|
||||||
|
- `OriginOperationPhase`: operation lifecycle phase.
|
||||||
|
- `OriginOperationIntent`: selected operation outcome.
|
||||||
|
- `OriginRect`: top, left, width, and height.
|
||||||
|
|
||||||
|
### Internal types
|
||||||
|
|
||||||
|
`OriginNodeScope` and `MutableOriginOperation` are renderer/runtime
|
||||||
|
implementation types. They are exported by the current barrel but marked
|
||||||
|
`@internal` and should not be application dependencies.
|
||||||
|
|
||||||
|
## Errors and constraints
|
||||||
|
|
||||||
|
- `useOrigin()` and `useOriginGesture()` must run inside a component mounted by
|
||||||
|
`OriginScene`.
|
||||||
|
- An origin can own only one outgoing operation at a time.
|
||||||
|
- Parked 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.
|
||||||
|
- Builder edges accept CSS lengths; arbitrary start policy belongs in
|
||||||
|
`.from.when()`.
|
||||||
|
- Every pushed history entry retains its Vue instance and DOM until a committed
|
||||||
|
back operation pops it. There is no eviction policy yet.
|
||||||
|
- Parked instances remain mounted, so their ordinary Vue effects and timers
|
||||||
|
continue running.
|
||||||
|
- A choreography creates one target. Chaining supports any number of
|
||||||
|
simultaneously mounted targets.
|
||||||
|
- Reduced-motion preference resolves settling immediately.
|
||||||
295
packages/core-v2/README.md
Normal file
295
packages/core-v2/README.md
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
# Core v2: routeless origins
|
||||||
|
|
||||||
|
`@native-vue-router/core-v2` is an experimental, Vue-only scene compositor. It
|
||||||
|
does not install Vue Router, resolve URLs, select a globally active route, or
|
||||||
|
render through `RouterView`.
|
||||||
|
|
||||||
|
The complete function, component, option, type, gesture-edge, and choreography
|
||||||
|
reference is in [API.md](./API.md).
|
||||||
|
|
||||||
|
The primitive is:
|
||||||
|
|
||||||
|
> A mounted component can originate a routine that creates another component,
|
||||||
|
> moves both components relative to the origin's coordinate frame, and retains
|
||||||
|
> the previous instance until a committed back operation pops the newer entry.
|
||||||
|
|
||||||
|
## Run the experiment
|
||||||
|
|
||||||
|
From the workspace root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run dev:v2
|
||||||
|
```
|
||||||
|
|
||||||
|
Open the printed URL to explore 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;
|
||||||
|
- three nested scenes demonstrating cooperative carousels, vertical decks, and
|
||||||
|
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,
|
||||||
|
swipe rapidly through X → Y → Z → Ω to see all four components mounted while
|
||||||
|
their independent frames are still moving.
|
||||||
|
|
||||||
|
An installable, offline-capable PWA build of the same experiment is hosted at
|
||||||
|
<https://v2.demo.native-router.harvmaster.com/>.
|
||||||
|
|
||||||
|
## Basic usage
|
||||||
|
|
||||||
|
Create a scene with a component recipe:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createOriginScene, originView } from "@native-vue-router/core-v2";
|
||||||
|
import "@native-vue-router/core-v2/style.css";
|
||||||
|
import HomeView from "./HomeView.vue";
|
||||||
|
|
||||||
|
export const scene = createOriginScene({
|
||||||
|
initial: originView(HomeView, { accountId: "42" }, { key: "home" }),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Render it:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { OriginScene } from "@native-vue-router/core-v2";
|
||||||
|
import { scene } from "./scene";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<OriginScene :scene="scene" />
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
Declare an interaction inside the component that should originate it:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
OriginGesture,
|
||||||
|
forward,
|
||||||
|
gesture,
|
||||||
|
originView,
|
||||||
|
slideLeft,
|
||||||
|
} from "@native-vue-router/core-v2";
|
||||||
|
import ProfileView from "./ProfileView.vue";
|
||||||
|
|
||||||
|
const openProfile = gesture.to
|
||||||
|
.left()
|
||||||
|
.navigate(() =>
|
||||||
|
forward(originView(ProfileView, { userId: "7" }, { key: "profile-7" })),
|
||||||
|
)
|
||||||
|
.animate(slideLeft);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<OriginGesture :gesture="openProfile">
|
||||||
|
<main>Swipe this component left</main>
|
||||||
|
</OriginGesture>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
Starting directly at `.to.left()` means pointer-down may occur anywhere on the
|
||||||
|
host. Add `.from.left("clamp(24px, 8%, 64px)")` before `.to.right()` for a
|
||||||
|
conventional proportional back edge, or `.from.when(context => ...)` for
|
||||||
|
arbitrary start policy. `.complete()` can override the choreography's release
|
||||||
|
thresholds.
|
||||||
|
|
||||||
|
There is no global navigation declaration. If this component should not
|
||||||
|
support that gesture, it simply does not render `OriginGesture`.
|
||||||
|
|
||||||
|
For an existing element where an additional wrapper is undesirable, use
|
||||||
|
`useOriginGesture()` and attach its four pointer handlers directly.
|
||||||
|
|
||||||
|
For several gestures on one page surface, keep the definitions in the page and
|
||||||
|
pass them to the policy-neutral host:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { OriginGestureSurface } from "@native-vue-router/core-v2";
|
||||||
|
|
||||||
|
const gestures = [forwardGesture, backGesture] as const;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<OriginGestureSurface as="main" :gestures="gestures">
|
||||||
|
...
|
||||||
|
</OriginGestureSurface>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Going backward
|
||||||
|
|
||||||
|
Every pushed history entry remains mounted. Back resolves the already-mounted
|
||||||
|
previous node and pops only the current entry after the operation commits:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
back,
|
||||||
|
gesture,
|
||||||
|
slideRight,
|
||||||
|
useOriginGesture,
|
||||||
|
} from "@native-vue-router/core-v2";
|
||||||
|
|
||||||
|
const goBack = useOriginGesture(
|
||||||
|
gesture.from
|
||||||
|
.left("max(24px, 6%)")
|
||||||
|
.to.right()
|
||||||
|
.navigate((context) => (context.canGoBack ? back() : null))
|
||||||
|
.animate(slideRight),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Parked entries are visually hidden, inert, and removed from pointer and
|
||||||
|
accessibility interaction. Their Vue instances and DOM remain mounted, so
|
||||||
|
component-local state and nested element scroll positions are preserved
|
||||||
|
naturally. A cancelled back re-parks the previous target; a committed back
|
||||||
|
unmounts the entry being left.
|
||||||
|
|
||||||
|
The application chooses whether this is exposed as a left-edge gesture,
|
||||||
|
toolbar button, keyboard shortcut, Android hardware-back action, or not exposed
|
||||||
|
at all.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
shared frame:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { defineOriginChoreography } from "@native-vue-router/core-v2";
|
||||||
|
|
||||||
|
export const zoomFromCard = defineOriginChoreography({
|
||||||
|
name: "zoom-from-card",
|
||||||
|
commitThreshold: 0.42,
|
||||||
|
effects: ({ progress, originRect, viewport }) => ({
|
||||||
|
source: {
|
||||||
|
transform: `scale(${1 - progress * 0.08})`,
|
||||||
|
opacity: 1 - progress * 0.4,
|
||||||
|
},
|
||||||
|
target: {
|
||||||
|
transform: `translateY(${(1 - progress) * viewport.height}px)`,
|
||||||
|
style: {
|
||||||
|
borderRadius: `${(1 - progress) * 24}px`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
`originRect`, `targetRect`, and the scene viewport are measured after the
|
||||||
|
target mounts. The gesture may update progress interactively or a normal click
|
||||||
|
can call `useOrigin().perform(action)`.
|
||||||
|
|
||||||
|
Transforms are concatenated from the oldest origin frame to the newest local
|
||||||
|
effect. Opacity is multiplied. Other properties in `style` use local-last
|
||||||
|
precedence. Consequently, if X→Y and Y→Z overlap:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Y transform = (X→Y target) × (Y→Z source)
|
||||||
|
Z transform = (X→Y target) × (Y→Z target)
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
component has one stable, keyed host directly under `OriginScene`.
|
||||||
|
|
||||||
|
If Y were physically moved from an X→Y wrapper to the scene root when an edge
|
||||||
|
collapsed, Vue would unmount and recreate Y. Instead, v2 rewrites graph edges
|
||||||
|
and recalculates Y's effect layers while every retained VNode stays in the same
|
||||||
|
flat host.
|
||||||
|
|
||||||
|
“Y is Z's origin” is a coordinate and retained-history relationship, not Vue
|
||||||
|
component ancestry.
|
||||||
|
|
||||||
|
## Current experimental boundaries
|
||||||
|
|
||||||
|
- One operation creates one target. Chaining operations already permits any
|
||||||
|
number of simultaneous scene nodes; multi-target routines are not yet
|
||||||
|
exposed as a public builder.
|
||||||
|
- A node can originate one outgoing operation at a time. Its created target
|
||||||
|
may immediately originate the next operation.
|
||||||
|
- The included pointer recognizer handles one primary pointer and one axis.
|
||||||
|
Choreographies and scene operations are independent of it.
|
||||||
|
- Multiple recognizers can share a host and arbitrate by start policy and
|
||||||
|
directional intent. A dedicated multi-pointer gesture arena is not exposed.
|
||||||
|
- Nested `OriginScene` components have independent history and measurements.
|
||||||
|
Child recognizers currently claim propagation at pointer-down, so yielding a
|
||||||
|
region to a parent requires an explicit `.from` policy.
|
||||||
|
- Every pushed history entry remains mounted until back pops it. There is not
|
||||||
|
yet an eviction policy, so applications should deliberately reset long-lived
|
||||||
|
navigation contexts when that API is introduced.
|
||||||
|
- Parked instances remain mounted and ordinary Vue timers/watchers continue to
|
||||||
|
run. Engine-specific park/resume lifecycle hooks are not exposed yet.
|
||||||
|
- Arbitrary CSS properties can be used, but only transforms and opacity have
|
||||||
|
defined multi-operation composition rules at present.
|
||||||
|
|
||||||
|
These boundaries are explicit so the experiment can validate the origin
|
||||||
|
primitive before compatibility conveniences become permanent architecture.
|
||||||
27
packages/core-v2/package.json
Normal file
27
packages/core-v2/package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "@native-vue-router/core-v2",
|
||||||
|
"version": "0.1.0-experimental.0",
|
||||||
|
"type": "module",
|
||||||
|
"license": "MIT",
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"API.md",
|
||||||
|
"README.md"
|
||||||
|
],
|
||||||
|
"sideEffects": [
|
||||||
|
"./dist/style.css"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "vite build --config vite.config.ts && vue-tsc -p tsconfig.json --emitDeclarationOnly"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.5.0"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./style.css": "./dist/style.css"
|
||||||
|
}
|
||||||
|
}
|
||||||
41
packages/core-v2/src/components/OriginGesture.vue
Normal file
41
packages/core-v2/src/components/OriginGesture.vue
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { useOriginGesture } from "../gesture";
|
||||||
|
import type { OriginGestureProps } from "../types";
|
||||||
|
|
||||||
|
defineOptions({ name: "OriginGesture", inheritAttrs: false });
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<OriginGestureProps>(), {
|
||||||
|
as: "div",
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This convenience component makes the declaration live exactly where the
|
||||||
|
* developer writes it. `useOriginGesture()` is also public for components that
|
||||||
|
* cannot accept an extra wrapper element.
|
||||||
|
*/
|
||||||
|
const gesture = useOriginGesture(
|
||||||
|
props.gesture ?? {
|
||||||
|
direction: props.direction,
|
||||||
|
edge: props.edge,
|
||||||
|
threshold: props.threshold,
|
||||||
|
action: (context) => props.action?.(context),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const touchStyle = computed(() => gesture.style);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="as"
|
||||||
|
v-bind="$attrs"
|
||||||
|
class="nvo-gesture"
|
||||||
|
:style="touchStyle"
|
||||||
|
@pointerdown="gesture.onPointerdown"
|
||||||
|
@pointermove="gesture.onPointermove"
|
||||||
|
@pointerup="gesture.onPointerup"
|
||||||
|
@pointercancel="gesture.onPointercancel"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
73
packages/core-v2/src/components/OriginGestureSurface.vue
Normal file
73
packages/core-v2/src/components/OriginGestureSurface.vue
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { useOriginGesture } from "../gesture";
|
||||||
|
import type { OriginGestureSurfaceProps } from "../types";
|
||||||
|
|
||||||
|
defineOptions({ name: "OriginGestureSurface", inheritAttrs: false });
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<OriginGestureSurfaceProps>(), {
|
||||||
|
as: "div",
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* All interaction policy belongs to the component that built the definitions.
|
||||||
|
* This convenience host only installs them, combines their browser scrolling
|
||||||
|
* requirements, and forwards a pointer sequence to every recognizer.
|
||||||
|
*/
|
||||||
|
const bindings = props.gestures.map((definition) =>
|
||||||
|
useOriginGesture(definition),
|
||||||
|
);
|
||||||
|
|
||||||
|
const surfaceStyle = computed(() => {
|
||||||
|
const horizontal = props.gestures.some(
|
||||||
|
({ direction }) => direction === "left" || direction === "right",
|
||||||
|
);
|
||||||
|
const vertical = props.gestures.some(
|
||||||
|
({ direction }) => direction === "up" || direction === "down",
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
touchAction:
|
||||||
|
horizontal && vertical
|
||||||
|
? "none"
|
||||||
|
: horizontal
|
||||||
|
? "pan-y"
|
||||||
|
: vertical
|
||||||
|
? "pan-x"
|
||||||
|
: "auto",
|
||||||
|
} as const;
|
||||||
|
});
|
||||||
|
|
||||||
|
function pointerDown(event: PointerEvent) {
|
||||||
|
for (const binding of bindings) binding.onPointerdown(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointerMove(event: PointerEvent) {
|
||||||
|
for (const binding of bindings) binding.onPointermove(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointerUp(event: PointerEvent) {
|
||||||
|
for (const binding of bindings) binding.onPointerup(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointerCancel() {
|
||||||
|
for (const binding of bindings) binding.onPointercancel();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="as"
|
||||||
|
v-bind="$attrs"
|
||||||
|
class="nvo-gesture"
|
||||||
|
:style="[$attrs.style, surfaceStyle]"
|
||||||
|
@pointerdown="pointerDown"
|
||||||
|
@pointermove="pointerMove"
|
||||||
|
@pointerup="pointerUp"
|
||||||
|
@pointercancel="pointerCancel"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
52
packages/core-v2/src/components/OriginNodeHost.vue
Normal file
52
packages/core-v2/src/components/OriginNodeHost.vue
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, provide, ref, watchEffect } from "vue";
|
||||||
|
import type { OriginScene, OriginSceneNode } from "../types";
|
||||||
|
import { originNodeScopeKey } from "../lifecycle";
|
||||||
|
|
||||||
|
defineOptions({ name: "OriginNodeHost" });
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
scene: OriginScene;
|
||||||
|
node: OriginSceneNode;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This host is the stable physical home of the view component. It is keyed by
|
||||||
|
* the scene node in OriginScene and never nested under another view. Only its
|
||||||
|
* composed CSS style changes while operation edges are created and collapsed.
|
||||||
|
*/
|
||||||
|
const host = ref<HTMLElement | null>(null);
|
||||||
|
provide(originNodeScopeKey, {
|
||||||
|
scene: props.scene,
|
||||||
|
nodeKey: props.node.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
props.scene.registerElement(props.node.key, host.value);
|
||||||
|
});
|
||||||
|
onBeforeUnmount(() => props.scene.registerElement(props.node.key, null));
|
||||||
|
|
||||||
|
const style = computed(() => props.scene.styleForNode(props.node.key));
|
||||||
|
const interactive = computed(() =>
|
||||||
|
props.scene.isNodeInteractive(props.node.key),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
ref="host"
|
||||||
|
class="nvo-node"
|
||||||
|
:style="style"
|
||||||
|
:data-origin-node="node.key"
|
||||||
|
:data-origin-view="node.view.name ?? node.view.key"
|
||||||
|
:data-origin-state="node.state"
|
||||||
|
:aria-hidden="interactive ? undefined : 'true'"
|
||||||
|
:inert="interactive ? undefined : true"
|
||||||
|
>
|
||||||
|
<!--
|
||||||
|
Vue owns the component lifecycle normally. Adding another origin merely
|
||||||
|
adds effect layers to this host; it does not replace this component VNode.
|
||||||
|
-->
|
||||||
|
<component :is="node.view.component" v-bind="node.view.props" />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
38
packages/core-v2/src/components/OriginScene.vue
Normal file
38
packages/core-v2/src/components/OriginScene.vue
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, ref, watchEffect } from "vue";
|
||||||
|
import type { OriginSceneProps } from "../types";
|
||||||
|
import OriginNodeHost from "./OriginNodeHost.vue";
|
||||||
|
|
||||||
|
defineOptions({ name: "OriginScene" });
|
||||||
|
|
||||||
|
const props = defineProps<OriginSceneProps>();
|
||||||
|
|
||||||
|
const root = ref<HTMLElement | null>(null);
|
||||||
|
watchEffect(() => props.scene.registerContainer(root.value));
|
||||||
|
onBeforeUnmount(() => props.scene.registerContainer(null));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!--
|
||||||
|
All component hosts are siblings. The operation graph is intentionally not
|
||||||
|
mirrored as DOM ancestry because promoting Y after X→Y must not remount Y.
|
||||||
|
-->
|
||||||
|
<main
|
||||||
|
ref="root"
|
||||||
|
class="nvo-scene"
|
||||||
|
:style="{
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
isolation: 'isolate',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<OriginNodeHost
|
||||||
|
v-for="node in scene.nodes.value"
|
||||||
|
:key="node.key"
|
||||||
|
:scene="scene"
|
||||||
|
:node="node"
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
258
packages/core-v2/src/gesture.test.ts
Normal file
258
packages/core-v2/src/gesture.test.ts
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
import { createApp, defineComponent, h, nextTick, type Component } from "vue";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import OriginGestureSurface from "./components/OriginGestureSurface.vue";
|
||||||
|
import OriginScene from "./components/OriginScene.vue";
|
||||||
|
import { gesture, useOriginGesture } from "./gesture";
|
||||||
|
import { back, defineOriginChoreography, forward, replace } from "./motion";
|
||||||
|
import { createOriginScene, originView } from "./scene";
|
||||||
|
import type {
|
||||||
|
OriginGestureBinding,
|
||||||
|
OriginGestureCompletionContext,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
const mountedApps: Array<ReturnType<typeof createApp>> = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const app of mountedApps.splice(0)) app.unmount();
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const testMotion = defineOriginChoreography({
|
||||||
|
name: "gesture-test",
|
||||||
|
effects: ({ progress }) => ({
|
||||||
|
source: { opacity: 1 - progress },
|
||||||
|
target: { opacity: progress },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
function component(name: string): Component {
|
||||||
|
return defineComponent({
|
||||||
|
name,
|
||||||
|
render: () => h("div", name),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointer(
|
||||||
|
type: string,
|
||||||
|
init: Pick<PointerEventInit, "clientX" | "clientY">,
|
||||||
|
) {
|
||||||
|
return new PointerEvent(type, {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
button: 0,
|
||||||
|
isPrimary: true,
|
||||||
|
pointerId: 7,
|
||||||
|
...init,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushAsyncHandlers() {
|
||||||
|
await Promise.resolve();
|
||||||
|
await nextTick();
|
||||||
|
await Promise.resolve();
|
||||||
|
await nextTick();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("gesture builder", () => {
|
||||||
|
it("installs multiple page-owned definitions on a policy-neutral surface", async () => {
|
||||||
|
const horizontal = gesture.to
|
||||||
|
.left()
|
||||||
|
.navigate(() => null)
|
||||||
|
.animate(testMotion);
|
||||||
|
const vertical = gesture.from
|
||||||
|
.top("12%")
|
||||||
|
.to.down()
|
||||||
|
.navigate(() => null)
|
||||||
|
.animate(testMotion);
|
||||||
|
const Initial = defineComponent({
|
||||||
|
name: "SurfaceInitial",
|
||||||
|
render: () =>
|
||||||
|
h(
|
||||||
|
OriginGestureSurface,
|
||||||
|
{
|
||||||
|
as: "section",
|
||||||
|
id: "multi-gesture-surface",
|
||||||
|
gestures: [horizontal, vertical],
|
||||||
|
},
|
||||||
|
() => "surface",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const scene = createOriginScene({
|
||||||
|
initial: originView(Initial, undefined, { key: "surface-initial" }),
|
||||||
|
});
|
||||||
|
const root = document.createElement("div");
|
||||||
|
document.body.append(root);
|
||||||
|
const app = createApp({ render: () => h(OriginScene, { scene }) });
|
||||||
|
mountedApps.push(app);
|
||||||
|
app.mount(root);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const surface = root.querySelector("#multi-gesture-surface") as HTMLElement;
|
||||||
|
expect(surface.tagName).toBe("SECTION");
|
||||||
|
expect(surface.classList.contains("nvo-gesture")).toBe(true);
|
||||||
|
expect(surface.style.touchAction).toBe("none");
|
||||||
|
expect(surface.style.width).toBe("100%");
|
||||||
|
expect(surface.style.height).toBe("100%");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps builder navigation intents separate from complete actions", () => {
|
||||||
|
const target = originView(component("Target"));
|
||||||
|
|
||||||
|
expect(forward(target)).toEqual({
|
||||||
|
target,
|
||||||
|
placement: "above",
|
||||||
|
history: "push",
|
||||||
|
});
|
||||||
|
expect(back()).toEqual({
|
||||||
|
placement: "under",
|
||||||
|
history: "back",
|
||||||
|
});
|
||||||
|
expect(replace(target)).toEqual({
|
||||||
|
target,
|
||||||
|
placement: "above",
|
||||||
|
history: "replace",
|
||||||
|
});
|
||||||
|
expect(forward(target, testMotion)).toMatchObject({
|
||||||
|
target,
|
||||||
|
choreography: testMotion,
|
||||||
|
history: "push",
|
||||||
|
});
|
||||||
|
expect(back(testMotion)).toMatchObject({
|
||||||
|
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", () => {
|
||||||
|
const definition = gesture.to
|
||||||
|
.right({ threshold: 12 })
|
||||||
|
.navigate(() => back())
|
||||||
|
.animate(testMotion);
|
||||||
|
|
||||||
|
expect(definition).toMatchObject({
|
||||||
|
kind: "origin-gesture-definition",
|
||||||
|
start: { kind: "anywhere" },
|
||||||
|
direction: "right",
|
||||||
|
recognition: { threshold: 12 },
|
||||||
|
choreography: testMotion,
|
||||||
|
});
|
||||||
|
expect(Object.isFrozen(definition)).toBe(true);
|
||||||
|
expect(Object.isFrozen(definition.recognition)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps start predicates independent from movement direction", () => {
|
||||||
|
const predicate = vi.fn(() => true);
|
||||||
|
const complete = vi.fn(() => true);
|
||||||
|
const definition = gesture.from
|
||||||
|
.when(predicate)
|
||||||
|
.to.down({ axisDominance: 1.4 })
|
||||||
|
.complete(complete)
|
||||||
|
.navigate(() => forward(originView(component("Dialog"))))
|
||||||
|
.animate(testMotion);
|
||||||
|
|
||||||
|
expect(definition.start).toEqual({ kind: "when", predicate });
|
||||||
|
expect(definition.direction).toBe("down");
|
||||||
|
expect(definition.recognition.axisDominance).toBe(1.4);
|
||||||
|
expect(definition.completion).toBe(complete);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes .to.right anywhere and lets .complete override release", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"matchMedia",
|
||||||
|
vi.fn(() => ({ matches: true }) as MediaQueryList),
|
||||||
|
);
|
||||||
|
const Target = component("Target");
|
||||||
|
let binding: OriginGestureBinding | undefined;
|
||||||
|
let completion: OriginGestureCompletionContext | undefined;
|
||||||
|
const definition = gesture.to
|
||||||
|
.right()
|
||||||
|
.complete((context) => {
|
||||||
|
completion = context;
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
.navigate(() => forward(originView(Target, undefined, { key: "target" })))
|
||||||
|
.animate(testMotion);
|
||||||
|
|
||||||
|
const Initial = defineComponent({
|
||||||
|
name: "Initial",
|
||||||
|
setup() {
|
||||||
|
binding = useOriginGesture(definition);
|
||||||
|
return () =>
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
id: "gesture-host",
|
||||||
|
style: binding!.style,
|
||||||
|
onPointerdown: binding!.onPointerdown,
|
||||||
|
onPointermove: binding!.onPointermove,
|
||||||
|
onPointerup: binding!.onPointerup,
|
||||||
|
onPointercancel: binding!.onPointercancel,
|
||||||
|
},
|
||||||
|
"Initial",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const scene = createOriginScene({
|
||||||
|
initial: originView(Initial, undefined, { key: "initial" }),
|
||||||
|
});
|
||||||
|
const root = document.createElement("div");
|
||||||
|
document.body.append(root);
|
||||||
|
const app = createApp({ render: () => h(OriginScene, { scene }) });
|
||||||
|
mountedApps.push(app);
|
||||||
|
app.mount(root);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const host = root.querySelector("#gesture-host") as HTMLElement;
|
||||||
|
Object.defineProperties(host, {
|
||||||
|
clientWidth: { configurable: true, value: 200 },
|
||||||
|
clientHeight: { configurable: true, value: 400 },
|
||||||
|
});
|
||||||
|
host.getBoundingClientRect = () =>
|
||||||
|
({
|
||||||
|
top: 20,
|
||||||
|
left: 100,
|
||||||
|
right: 300,
|
||||||
|
bottom: 420,
|
||||||
|
width: 200,
|
||||||
|
height: 400,
|
||||||
|
x: 100,
|
||||||
|
y: 20,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
}) as DOMRect;
|
||||||
|
|
||||||
|
// x=250 is nowhere near the left edge. With no `.from`, it is eligible.
|
||||||
|
host.dispatchEvent(pointer("pointerdown", { clientX: 250, clientY: 100 }));
|
||||||
|
host.dispatchEvent(pointer("pointermove", { clientX: 330, clientY: 102 }));
|
||||||
|
await flushAsyncHandlers();
|
||||||
|
expect(scene.operations.value).toHaveLength(1);
|
||||||
|
|
||||||
|
host.dispatchEvent(pointer("pointerup", { clientX: 350, clientY: 102 }));
|
||||||
|
await flushAsyncHandlers();
|
||||||
|
|
||||||
|
expect(completion).toMatchObject({
|
||||||
|
direction: "right",
|
||||||
|
progress: 0.5,
|
||||||
|
distance: 100,
|
||||||
|
crossDistance: 2,
|
||||||
|
});
|
||||||
|
expect(completion?.start).toMatchObject({
|
||||||
|
clientX: 250,
|
||||||
|
localX: 150,
|
||||||
|
});
|
||||||
|
expect(completion?.current).toMatchObject({
|
||||||
|
clientX: 350,
|
||||||
|
localX: 250,
|
||||||
|
});
|
||||||
|
expect(scene.operations.value).toHaveLength(0);
|
||||||
|
expect(scene.nodes.value.map((node) => node.view.name)).toEqual([
|
||||||
|
"Initial",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
582
packages/core-v2/src/gesture.ts
Normal file
582
packages/core-v2/src/gesture.ts
Normal file
@@ -0,0 +1,582 @@
|
|||||||
|
import type {
|
||||||
|
MaybeOriginAction,
|
||||||
|
OriginAction,
|
||||||
|
OriginContext,
|
||||||
|
OriginGestureBinding,
|
||||||
|
OriginGestureBuilder,
|
||||||
|
OriginGestureCompletionContext,
|
||||||
|
OriginGestureCompletionPredicate,
|
||||||
|
OriginGestureDefinition,
|
||||||
|
OriginGestureDirection,
|
||||||
|
OriginGestureDirectionOptions,
|
||||||
|
OriginGestureDistance,
|
||||||
|
OriginGestureEdge,
|
||||||
|
OriginGestureFromBuilder,
|
||||||
|
OriginGestureFromSelection,
|
||||||
|
OriginGestureNavigationBuilder,
|
||||||
|
OriginGestureNavigationFactory,
|
||||||
|
OriginGestureOptions,
|
||||||
|
OriginGesturePoint,
|
||||||
|
OriginGestureStart,
|
||||||
|
OriginGestureStartContext,
|
||||||
|
OriginGestureStartPredicate,
|
||||||
|
OriginGestureToBuilder,
|
||||||
|
OriginOperationHandle,
|
||||||
|
OriginRect,
|
||||||
|
} from "./types";
|
||||||
|
import { useOrigin } from "./lifecycle";
|
||||||
|
|
||||||
|
function ignoreGestureTarget(target: EventTarget | null) {
|
||||||
|
return (
|
||||||
|
!(target instanceof Element) ||
|
||||||
|
Boolean(
|
||||||
|
target.closest(
|
||||||
|
'[data-origin-gesture="ignore"], input, textarea, select, option, [contenteditable="true"]',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function directedDistance(
|
||||||
|
direction: OriginGestureDirection,
|
||||||
|
dx: number,
|
||||||
|
dy: number,
|
||||||
|
) {
|
||||||
|
switch (direction) {
|
||||||
|
case "left":
|
||||||
|
return -dx;
|
||||||
|
case "right":
|
||||||
|
return dx;
|
||||||
|
case "up":
|
||||||
|
return -dy;
|
||||||
|
case "down":
|
||||||
|
return dy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rectOf(element: HTMLElement): OriginRect {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
top: rect.top,
|
||||||
|
left: rect.left,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointOf(
|
||||||
|
event: Pick<PointerEvent, "clientX" | "clientY">,
|
||||||
|
bounds: OriginRect,
|
||||||
|
): OriginGesturePoint {
|
||||||
|
return {
|
||||||
|
clientX: event.clientX,
|
||||||
|
clientY: event.clientY,
|
||||||
|
localX: event.clientX - bounds.left,
|
||||||
|
localY: event.clientY - bounds.top,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an arbitrary CSS length against a box with the gesture host's size.
|
||||||
|
*
|
||||||
|
* A short-lived off-screen box lets the browser handle `rem`, viewport units,
|
||||||
|
* percentages, `calc()`, and `clamp()` consistently. This runs only during
|
||||||
|
* pointer-down for edge-constrained definitions.
|
||||||
|
*/
|
||||||
|
function resolveCssDistance(
|
||||||
|
distance: OriginGestureDistance,
|
||||||
|
axis: "horizontal" | "vertical",
|
||||||
|
host: HTMLElement,
|
||||||
|
bounds: OriginRect,
|
||||||
|
) {
|
||||||
|
if (typeof distance === "number")
|
||||||
|
return Number.isFinite(distance) ? Math.max(0, distance) : 0;
|
||||||
|
|
||||||
|
const document = host.ownerDocument;
|
||||||
|
if (!document.body) return Math.max(0, Number.parseFloat(distance) || 0);
|
||||||
|
|
||||||
|
const container = document.createElement("div");
|
||||||
|
const probe = document.createElement("div");
|
||||||
|
Object.assign(container.style, {
|
||||||
|
position: "fixed",
|
||||||
|
left: "-100000px",
|
||||||
|
top: "-100000px",
|
||||||
|
width: `${bounds.width}px`,
|
||||||
|
height: `${bounds.height}px`,
|
||||||
|
visibility: "hidden",
|
||||||
|
pointerEvents: "none",
|
||||||
|
contain: "strict",
|
||||||
|
});
|
||||||
|
Object.assign(probe.style, {
|
||||||
|
position: "absolute",
|
||||||
|
width: axis === "horizontal" ? distance : "0",
|
||||||
|
height: axis === "vertical" ? distance : "0",
|
||||||
|
});
|
||||||
|
container.append(probe);
|
||||||
|
document.body.append(container);
|
||||||
|
const resolved =
|
||||||
|
axis === "horizontal"
|
||||||
|
? probe.getBoundingClientRect().width
|
||||||
|
: probe.getBoundingClientRect().height;
|
||||||
|
container.remove();
|
||||||
|
return Number.isFinite(resolved) ? Math.max(0, resolved) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesStart(
|
||||||
|
start: OriginGestureStart,
|
||||||
|
event: PointerEvent,
|
||||||
|
host: HTMLElement,
|
||||||
|
origin: OriginContext,
|
||||||
|
bounds: OriginRect,
|
||||||
|
) {
|
||||||
|
if (start.kind === "anywhere") return true;
|
||||||
|
const point = pointOf(event, bounds);
|
||||||
|
if (start.kind === "when") {
|
||||||
|
const context: OriginGestureStartContext = {
|
||||||
|
event,
|
||||||
|
origin,
|
||||||
|
host,
|
||||||
|
bounds,
|
||||||
|
point,
|
||||||
|
};
|
||||||
|
return start.predicate(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
const horizontal = start.edge === "left" || start.edge === "right";
|
||||||
|
const distance = resolveCssDistance(
|
||||||
|
start.distance,
|
||||||
|
horizontal ? "horizontal" : "vertical",
|
||||||
|
host,
|
||||||
|
bounds,
|
||||||
|
);
|
||||||
|
switch (start.edge) {
|
||||||
|
case "left":
|
||||||
|
return point.localX >= 0 && point.localX <= distance;
|
||||||
|
case "right":
|
||||||
|
return (
|
||||||
|
point.localX <= bounds.width && bounds.width - point.localX <= distance
|
||||||
|
);
|
||||||
|
case "top":
|
||||||
|
return point.localY >= 0 && point.localY <= distance;
|
||||||
|
case "bottom":
|
||||||
|
return (
|
||||||
|
point.localY <= bounds.height &&
|
||||||
|
bounds.height - point.localY <= distance
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function edgeStart(
|
||||||
|
edge: OriginGestureEdge,
|
||||||
|
distance: OriginGestureDistance,
|
||||||
|
): OriginGestureStart {
|
||||||
|
return Object.freeze({ kind: "edge", edge, distance });
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNavigationBuilder(
|
||||||
|
start: OriginGestureStart,
|
||||||
|
direction: OriginGestureDirection,
|
||||||
|
recognition: Readonly<OriginGestureDirectionOptions>,
|
||||||
|
completion: OriginGestureCompletionPredicate | undefined,
|
||||||
|
navigation: OriginGestureNavigationFactory,
|
||||||
|
): OriginGestureNavigationBuilder {
|
||||||
|
return Object.freeze({
|
||||||
|
animate(choreography: OriginGestureDefinition["choreography"]) {
|
||||||
|
return Object.freeze({
|
||||||
|
kind: "origin-gesture-definition",
|
||||||
|
start,
|
||||||
|
direction,
|
||||||
|
recognition,
|
||||||
|
completion,
|
||||||
|
navigation,
|
||||||
|
choreography,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDirectedBuilder(
|
||||||
|
start: OriginGestureStart,
|
||||||
|
direction: OriginGestureDirection,
|
||||||
|
options: OriginGestureDirectionOptions = {},
|
||||||
|
) {
|
||||||
|
const recognition = Object.freeze({ ...options });
|
||||||
|
const navigate = (
|
||||||
|
navigation: OriginGestureNavigationFactory,
|
||||||
|
completion?: OriginGestureCompletionPredicate,
|
||||||
|
) =>
|
||||||
|
createNavigationBuilder(
|
||||||
|
start,
|
||||||
|
direction,
|
||||||
|
recognition,
|
||||||
|
completion,
|
||||||
|
navigation,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
complete(completion: OriginGestureCompletionPredicate) {
|
||||||
|
return Object.freeze({
|
||||||
|
navigate: (navigation: OriginGestureNavigationFactory) =>
|
||||||
|
navigate(navigation, completion),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
navigate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createToBuilder(start: OriginGestureStart): OriginGestureToBuilder {
|
||||||
|
return Object.freeze({
|
||||||
|
left: (options?: OriginGestureDirectionOptions) =>
|
||||||
|
createDirectedBuilder(start, "left", options),
|
||||||
|
right: (options?: OriginGestureDirectionOptions) =>
|
||||||
|
createDirectedBuilder(start, "right", options),
|
||||||
|
up: (options?: OriginGestureDirectionOptions) =>
|
||||||
|
createDirectedBuilder(start, "up", options),
|
||||||
|
down: (options?: OriginGestureDirectionOptions) =>
|
||||||
|
createDirectedBuilder(start, "down", options),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectStart(start: OriginGestureStart): OriginGestureFromSelection {
|
||||||
|
return Object.freeze({ to: createToBuilder(Object.freeze(start)) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromBuilder: OriginGestureFromBuilder = Object.freeze({
|
||||||
|
left: (distance: OriginGestureDistance) =>
|
||||||
|
selectStart(edgeStart("left", distance)),
|
||||||
|
right: (distance: OriginGestureDistance) =>
|
||||||
|
selectStart(edgeStart("right", distance)),
|
||||||
|
top: (distance: OriginGestureDistance) =>
|
||||||
|
selectStart(edgeStart("top", distance)),
|
||||||
|
bottom: (distance: OriginGestureDistance) =>
|
||||||
|
selectStart(edgeStart("bottom", distance)),
|
||||||
|
anywhere: () => selectStart({ kind: "anywhere" }),
|
||||||
|
when: (predicate: OriginGestureStartPredicate) =>
|
||||||
|
selectStart({ kind: "when", predicate }),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root of the immutable gesture builder.
|
||||||
|
*
|
||||||
|
* `.from` is optional. Starting at `.to` admits pointer-down anywhere on the
|
||||||
|
* bound host, exactly like `.from.anywhere().to`.
|
||||||
|
*
|
||||||
|
* @example Anywhere-to-right back gesture
|
||||||
|
* ```ts
|
||||||
|
* const swipeBack = gesture
|
||||||
|
* .to.right()
|
||||||
|
* .navigate((context) => context.canGoBack ? back() : null)
|
||||||
|
* .animate(slideRight);
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @example Predicate-gated gesture with custom completion
|
||||||
|
* ```ts
|
||||||
|
* const openPanel = gesture
|
||||||
|
* .from.when(({ point, bounds }) => point.localX <= bounds.width * 0.08)
|
||||||
|
* .to.down()
|
||||||
|
* .complete(({ progress, velocity }) => progress > 0.5 || velocity > 1)
|
||||||
|
* .navigate(() => above(originView(PanelView)))
|
||||||
|
* .animate(dropPanel);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const gesture: OriginGestureBuilder = Object.freeze({
|
||||||
|
from: fromBuilder,
|
||||||
|
to: createToBuilder(Object.freeze({ kind: "anywhere" })),
|
||||||
|
});
|
||||||
|
|
||||||
|
function isDefinition(
|
||||||
|
value: OriginGestureOptions | OriginGestureDefinition,
|
||||||
|
): value is OriginGestureDefinition {
|
||||||
|
return "kind" in value && value.kind === "origin-gesture-definition";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RuntimeGesturePolicy {
|
||||||
|
readonly direction: OriginGestureDirection;
|
||||||
|
readonly start: OriginGestureStart;
|
||||||
|
readonly threshold: number;
|
||||||
|
readonly axisDominance: number;
|
||||||
|
readonly completion?: OriginGestureCompletionPredicate;
|
||||||
|
readonly action: (context: OriginContext) => MaybeOriginAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
function legacyStart(options: OriginGestureOptions): OriginGestureStart {
|
||||||
|
if (options.edge === undefined) return { kind: "anywhere" };
|
||||||
|
switch (options.direction) {
|
||||||
|
case "left":
|
||||||
|
return edgeStart("right", options.edge);
|
||||||
|
case "right":
|
||||||
|
return edgeStart("left", options.edge);
|
||||||
|
case "up":
|
||||||
|
return edgeStart("bottom", options.edge);
|
||||||
|
case "down":
|
||||||
|
return edgeStart("top", options.edge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimePolicy(
|
||||||
|
options: OriginGestureOptions | OriginGestureDefinition,
|
||||||
|
): RuntimeGesturePolicy {
|
||||||
|
if (!isDefinition(options)) {
|
||||||
|
return {
|
||||||
|
direction: options.direction,
|
||||||
|
start: legacyStart(options),
|
||||||
|
threshold: options.threshold ?? 8,
|
||||||
|
axisDominance: 1.15,
|
||||||
|
action: options.action,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
direction: options.direction,
|
||||||
|
start: options.start,
|
||||||
|
threshold: options.recognition.threshold ?? 8,
|
||||||
|
axisDominance: options.recognition.axisDominance ?? 1.15,
|
||||||
|
completion: options.completion,
|
||||||
|
action: async (context) => {
|
||||||
|
const navigation = await options.navigation(context);
|
||||||
|
if (!navigation) return navigation;
|
||||||
|
const action: OriginAction = {
|
||||||
|
...navigation,
|
||||||
|
choreography: options.choreography,
|
||||||
|
};
|
||||||
|
return action;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install a component-owned pointer recognizer.
|
||||||
|
*
|
||||||
|
* Recognition is local to the element receiving these handlers. There is no
|
||||||
|
* application-wide gesture table and no lookup of a currently active view.
|
||||||
|
*
|
||||||
|
* Builder definitions may independently describe their pointer-down region
|
||||||
|
* and movement direction. Omitting `.from` recognizes pointer-down across the
|
||||||
|
* whole element. The legacy options object remains supported; its `edge` is
|
||||||
|
* inferred from the opposite side of its movement direction.
|
||||||
|
*
|
||||||
|
* Interactive controls and anything inside
|
||||||
|
* `[data-origin-gesture="ignore"]` are ignored automatically. The recognizer
|
||||||
|
* preserves native scrolling on the cross-axis through its returned style.
|
||||||
|
*
|
||||||
|
* @param definition - An immutable builder result or legacy recognizer options.
|
||||||
|
* @returns Pointer handlers and required host styles.
|
||||||
|
* @throws If called outside a component rendered by `OriginScene`.
|
||||||
|
*
|
||||||
|
* @example Builder-defined backward gesture
|
||||||
|
* ```ts
|
||||||
|
* const swipeBack = useOriginGesture(
|
||||||
|
* gesture
|
||||||
|
* .from.left("32px")
|
||||||
|
* .to.right()
|
||||||
|
* .navigate((context) => context.canGoBack ? back() : null)
|
||||||
|
* .animate(slideRight),
|
||||||
|
* );
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useOriginGesture(
|
||||||
|
definition: OriginGestureDefinition | OriginGestureOptions,
|
||||||
|
): OriginGestureBinding {
|
||||||
|
const origin = useOrigin();
|
||||||
|
const policy = runtimePolicy(definition);
|
||||||
|
let pointerId = -1;
|
||||||
|
let element: HTMLElement | null = null;
|
||||||
|
let bounds: OriginRect | null = null;
|
||||||
|
let originContext: OriginContext | null = null;
|
||||||
|
let startPoint: OriginGesturePoint | null = null;
|
||||||
|
let startTime = 0;
|
||||||
|
let startX = 0;
|
||||||
|
let startY = 0;
|
||||||
|
let lastCoordinate = 0;
|
||||||
|
let lastTime = 0;
|
||||||
|
let captured = false;
|
||||||
|
let generation = 0;
|
||||||
|
let handlePromise: Promise<OriginOperationHandle | null> | null = null;
|
||||||
|
let bufferedProgress = 0;
|
||||||
|
let bufferedVelocity = 0;
|
||||||
|
let bufferedDistance = 0;
|
||||||
|
let bufferedCrossDistance = 0;
|
||||||
|
|
||||||
|
const horizontal =
|
||||||
|
policy.direction === "left" || policy.direction === "right";
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
pointerId = -1;
|
||||||
|
element = null;
|
||||||
|
bounds = null;
|
||||||
|
originContext = null;
|
||||||
|
startPoint = null;
|
||||||
|
captured = false;
|
||||||
|
handlePromise = null;
|
||||||
|
bufferedProgress = 0;
|
||||||
|
bufferedVelocity = 0;
|
||||||
|
bufferedDistance = 0;
|
||||||
|
bufferedCrossDistance = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerdown(event: PointerEvent) {
|
||||||
|
const current = event.currentTarget;
|
||||||
|
if (
|
||||||
|
!event.isPrimary ||
|
||||||
|
event.button !== 0 ||
|
||||||
|
!(current instanceof HTMLElement) ||
|
||||||
|
ignoreGestureTarget(event.target)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const nextBounds = rectOf(current);
|
||||||
|
const nextOriginContext = origin.context.value;
|
||||||
|
if (
|
||||||
|
!matchesStart(policy.start, event, current, nextOriginContext, nextBounds)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// The component containing this declaration is the operation's origin.
|
||||||
|
event.stopPropagation();
|
||||||
|
generation += 1;
|
||||||
|
pointerId = event.pointerId;
|
||||||
|
element = current;
|
||||||
|
bounds = nextBounds;
|
||||||
|
originContext = nextOriginContext;
|
||||||
|
startPoint = pointOf(event, nextBounds);
|
||||||
|
startTime = event.timeStamp;
|
||||||
|
startX = event.clientX;
|
||||||
|
startY = event.clientY;
|
||||||
|
lastCoordinate = horizontal ? event.clientX : event.clientY;
|
||||||
|
lastTime = event.timeStamp;
|
||||||
|
captured = false;
|
||||||
|
handlePromise = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMetrics(event: PointerEvent, release = false) {
|
||||||
|
if (!element) return;
|
||||||
|
const dx = event.clientX - startX;
|
||||||
|
const dy = event.clientY - startY;
|
||||||
|
bufferedDistance = directedDistance(policy.direction, dx, dy);
|
||||||
|
bufferedCrossDistance = horizontal ? Math.abs(dy) : Math.abs(dx);
|
||||||
|
const size = Math.max(
|
||||||
|
1,
|
||||||
|
horizontal ? element.clientWidth : element.clientHeight,
|
||||||
|
);
|
||||||
|
const coordinate = horizontal ? event.clientX : event.clientY;
|
||||||
|
const coordinateDelta =
|
||||||
|
policy.direction === "left" || policy.direction === "up"
|
||||||
|
? lastCoordinate - coordinate
|
||||||
|
: coordinate - lastCoordinate;
|
||||||
|
const rawElapsed = event.timeStamp - lastTime;
|
||||||
|
const elapsed = Math.max(8, rawElapsed);
|
||||||
|
bufferedProgress = Math.max(0, Math.min(1, bufferedDistance / size));
|
||||||
|
/*
|
||||||
|
* Pointer-up commonly repeats the final pointer-move coordinate. Preserve
|
||||||
|
* that move's flick velocity for a prompt release, but decay it when the
|
||||||
|
* pointer was held still long enough for the flick to have ended.
|
||||||
|
*/
|
||||||
|
if (!release || coordinateDelta !== 0 || rawElapsed > 80)
|
||||||
|
bufferedVelocity = (coordinateDelta * 1000) / (elapsed * size);
|
||||||
|
lastCoordinate = coordinate;
|
||||||
|
lastTime = event.timeStamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPointermove(event: PointerEvent) {
|
||||||
|
if (event.pointerId !== pointerId || !element) return;
|
||||||
|
const dx = event.clientX - startX;
|
||||||
|
const dy = event.clientY - startY;
|
||||||
|
const distance = directedDistance(policy.direction, dx, dy);
|
||||||
|
const crossDistance = horizontal ? Math.abs(dy) : Math.abs(dx);
|
||||||
|
let metricsUpdated = false;
|
||||||
|
|
||||||
|
if (!captured) {
|
||||||
|
if (
|
||||||
|
distance < policy.threshold ||
|
||||||
|
distance < crossDistance * policy.axisDominance
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
captured = true;
|
||||||
|
element.setPointerCapture?.(pointerId);
|
||||||
|
event.preventDefault();
|
||||||
|
updateMetrics(event);
|
||||||
|
metricsUpdated = true;
|
||||||
|
const recognitionGeneration = generation;
|
||||||
|
const action = await Promise.resolve(
|
||||||
|
policy.action(origin.context.value),
|
||||||
|
).catch(() => null);
|
||||||
|
// An asynchronous target resolver may finish after the pointer was
|
||||||
|
// released or cancelled. It must not create an orphan scene operation.
|
||||||
|
if (recognitionGeneration !== generation || event.pointerId !== pointerId)
|
||||||
|
return;
|
||||||
|
if (!action) return reset();
|
||||||
|
handlePromise = origin.begin(action).catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
if (!metricsUpdated) updateMetrics(event);
|
||||||
|
const pending = handlePromise;
|
||||||
|
const handle = pending ? await pending : null;
|
||||||
|
if (pending === handlePromise)
|
||||||
|
handle?.update(bufferedProgress, bufferedVelocity);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPointerup(event: PointerEvent) {
|
||||||
|
if (event.pointerId !== pointerId) return;
|
||||||
|
if (captured) updateMetrics(event, true);
|
||||||
|
|
||||||
|
const pending = handlePromise;
|
||||||
|
const shouldFinish = captured;
|
||||||
|
const progress = bufferedProgress;
|
||||||
|
const velocity = bufferedVelocity;
|
||||||
|
const completion =
|
||||||
|
shouldFinish &&
|
||||||
|
policy.completion &&
|
||||||
|
element &&
|
||||||
|
bounds &&
|
||||||
|
originContext &&
|
||||||
|
startPoint
|
||||||
|
? policy.completion({
|
||||||
|
origin: originContext,
|
||||||
|
direction: policy.direction,
|
||||||
|
progress,
|
||||||
|
velocity,
|
||||||
|
distance: bufferedDistance,
|
||||||
|
crossDistance: bufferedCrossDistance,
|
||||||
|
duration: Math.max(0, event.timeStamp - startTime),
|
||||||
|
event,
|
||||||
|
host: element,
|
||||||
|
bounds,
|
||||||
|
start: startPoint,
|
||||||
|
current: pointOf(event, bounds),
|
||||||
|
} satisfies OriginGestureCompletionContext)
|
||||||
|
: undefined;
|
||||||
|
generation += 1;
|
||||||
|
reset();
|
||||||
|
|
||||||
|
const handle = pending ? await pending : null;
|
||||||
|
if (!shouldFinish || !handle) return;
|
||||||
|
handle.update(progress, velocity);
|
||||||
|
await handle.finish(
|
||||||
|
completion === undefined ? undefined : { commit: completion },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPointercancel() {
|
||||||
|
generation += 1;
|
||||||
|
const pending = handlePromise;
|
||||||
|
const shouldCancel = captured;
|
||||||
|
reset();
|
||||||
|
const handle = pending ? await pending : null;
|
||||||
|
if (shouldCancel) await handle?.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
style: {
|
||||||
|
// Preserve native scrolling perpendicular to the declared gesture.
|
||||||
|
touchAction: horizontal ? "pan-y" : "pan-x",
|
||||||
|
// OriginGesture is commonly the root returned by a view component.
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
},
|
||||||
|
onPointerdown,
|
||||||
|
onPointermove: (event) => void onPointermove(event),
|
||||||
|
onPointerup: (event) => void onPointerup(event),
|
||||||
|
onPointercancel: () => void onPointercancel(),
|
||||||
|
};
|
||||||
|
}
|
||||||
38
packages/core-v2/src/index.ts
Normal file
38
packages/core-v2/src/index.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Routeless, component-owned scene transitions and gesture recognition for Vue.
|
||||||
|
*
|
||||||
|
* The package renders flat, stable Vue component hosts and composes temporary
|
||||||
|
* origin-relative operation frames. It does not depend on Vue Router or choose
|
||||||
|
* a globally active view.
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from "./types";
|
||||||
|
export * from "./scene";
|
||||||
|
export * from "./motion";
|
||||||
|
export * from "./gesture";
|
||||||
|
export * from "./lifecycle";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience component that binds one `useOriginGesture()` recognizer to a
|
||||||
|
* rendered HTML element. See `OriginGestureProps` for its public props.
|
||||||
|
*/
|
||||||
|
export { default as OriginGesture } from "./components/OriginGesture.vue";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Policy-neutral host for multiple completed gesture definitions. The owning
|
||||||
|
* page builds each definition; this component only installs their recognizers
|
||||||
|
* and forwards pointer events across the shared surface.
|
||||||
|
*/
|
||||||
|
export { default as OriginGestureSurface } from "./components/OriginGestureSurface.vue";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renderer for an `OriginScene`. Every live view is mounted as a stable,
|
||||||
|
* absolutely positioned sibling beneath this component.
|
||||||
|
*/
|
||||||
|
export { default as OriginScene } from "./components/OriginScene.vue";
|
||||||
|
|
||||||
|
// Makes the library build emit dist/style.css. Applications should import the
|
||||||
|
// explicit `@native-vue-router/core-v2/style.css` export as shown in the README.
|
||||||
|
import "./style.css";
|
||||||
53
packages/core-v2/src/lifecycle.ts
Normal file
53
packages/core-v2/src/lifecycle.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { computed, inject, type InjectionKey } from "vue";
|
||||||
|
import type { OriginNodeScope, UseOrigin } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injection key used by the internal scene-node host to establish origin
|
||||||
|
* ownership for descendant components.
|
||||||
|
*
|
||||||
|
* Application code normally calls {@link useOrigin} instead of injecting this
|
||||||
|
* key directly.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export const originNodeScopeKey: InjectionKey<OriginNodeScope> =
|
||||||
|
Symbol("origin-node-scope");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Access the scene from the component that owns an interaction declaration.
|
||||||
|
*
|
||||||
|
* There is deliberately no `activeView`: the injected node is the origin
|
||||||
|
* because this component is where the event or application action occurred.
|
||||||
|
*
|
||||||
|
* @returns Node-scoped scene state and operation controls.
|
||||||
|
* @throws If called outside a component rendered by `OriginScene`.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const origin = useOrigin();
|
||||||
|
*
|
||||||
|
* function openProfile() {
|
||||||
|
* return origin.perform(
|
||||||
|
* forward(originView(ProfileView), slideLeft),
|
||||||
|
* );
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useOrigin(): UseOrigin {
|
||||||
|
const scope = inject(originNodeScopeKey);
|
||||||
|
if (!scope)
|
||||||
|
throw new Error("useOrigin() must be called inside an <OriginScene> view.");
|
||||||
|
|
||||||
|
const context = computed(() => scope.scene.contextFor(scope.nodeKey));
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodeKey: scope.nodeKey,
|
||||||
|
scene: scope.scene,
|
||||||
|
context,
|
||||||
|
view: computed(() => context.value.view),
|
||||||
|
previous: computed(() => context.value.previous),
|
||||||
|
canGoBack: computed(() => context.value.canGoBack),
|
||||||
|
begin: (action) => scope.scene.begin(scope.nodeKey, action),
|
||||||
|
perform: (action) => scope.scene.perform(scope.nodeKey, action),
|
||||||
|
};
|
||||||
|
}
|
||||||
370
packages/core-v2/src/motion.ts
Normal file
370
packages/core-v2/src/motion.ts
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
import type {
|
||||||
|
OriginAction,
|
||||||
|
OriginChoreography,
|
||||||
|
OriginEffect,
|
||||||
|
OriginEffectSet,
|
||||||
|
OriginHistoryMode,
|
||||||
|
OriginNavigationIntent,
|
||||||
|
OriginPlacement,
|
||||||
|
OriginView,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preserve type inference while declaring a custom choreography.
|
||||||
|
*
|
||||||
|
* The helper performs no runtime transformation. It gives custom routines a
|
||||||
|
* named, documented construction point and validates their shape in TypeScript.
|
||||||
|
*
|
||||||
|
* @param choreography - Side-effect-free visual effect calculator and optional
|
||||||
|
* release thresholds.
|
||||||
|
* @returns The same choreography object.
|
||||||
|
*/
|
||||||
|
export function defineOriginChoreography(
|
||||||
|
choreography: OriginChoreography,
|
||||||
|
): OriginChoreography {
|
||||||
|
return choreography;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options accepted by {@link originAction}. */
|
||||||
|
export interface OriginActionOptions {
|
||||||
|
/**
|
||||||
|
* Target stacking relationship during the operation.
|
||||||
|
*
|
||||||
|
* @defaultValue `"above"`
|
||||||
|
*/
|
||||||
|
placement?: OriginPlacement;
|
||||||
|
/**
|
||||||
|
* History mutation assigned to the created target node.
|
||||||
|
*
|
||||||
|
* @defaultValue `"push"`
|
||||||
|
*/
|
||||||
|
history?: OriginHistoryMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** History options shared by the {@link above} and {@link under} helpers. */
|
||||||
|
export interface OriginPlacementActionOptions {
|
||||||
|
/**
|
||||||
|
* History mutation assigned to the created target node.
|
||||||
|
*
|
||||||
|
* @defaultValue `"push"`
|
||||||
|
*/
|
||||||
|
history?: OriginHistoryMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stacking options accepted by retained-history navigation helpers. */
|
||||||
|
export interface OriginNavigationActionOptions {
|
||||||
|
/**
|
||||||
|
* Target stacking relationship during the operation.
|
||||||
|
*
|
||||||
|
* @defaultValue `"above"` for {@link forward} and {@link replace}, `"under"`
|
||||||
|
* for {@link back}
|
||||||
|
*/
|
||||||
|
placement?: OriginPlacement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isChoreography(
|
||||||
|
value: OriginChoreography | object | undefined,
|
||||||
|
): value is OriginChoreography {
|
||||||
|
return (
|
||||||
|
value !== undefined &&
|
||||||
|
"effects" in value &&
|
||||||
|
typeof value.effects === "function"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the scene mutation invoked by a click, gesture, hardware command, or
|
||||||
|
* any other application event. `above` and `under` affect stacking only; they
|
||||||
|
* do not imply a universal navigation direction.
|
||||||
|
*
|
||||||
|
* @param target - View recipe to create.
|
||||||
|
* @param choreography - Visual routine controlling the operation.
|
||||||
|
* @param options - Stacking and history behavior.
|
||||||
|
* @returns An action that can be passed to `begin()`, `perform()`, or returned
|
||||||
|
* from a gesture action factory.
|
||||||
|
*/
|
||||||
|
export function originAction(
|
||||||
|
target: OriginView,
|
||||||
|
choreography: OriginChoreography,
|
||||||
|
options: OriginActionOptions = {},
|
||||||
|
): OriginAction {
|
||||||
|
return {
|
||||||
|
target,
|
||||||
|
choreography,
|
||||||
|
placement: options.placement ?? "above",
|
||||||
|
history: options.history ?? "push",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a retained-history push intent or complete action.
|
||||||
|
*
|
||||||
|
* Committing the action parks its origin instance and leaves it mounted until
|
||||||
|
* a later committed {@link back} action pops the new entry. Omit choreography
|
||||||
|
* when declaring `.navigate()` inside a gesture builder; provide choreography
|
||||||
|
* when passing the result directly to `begin()` or `perform()`.
|
||||||
|
*
|
||||||
|
* @param target - View recipe created if the push commits.
|
||||||
|
* @param options - Stacking options for an animation-free navigation intent.
|
||||||
|
* @returns An animation-free intent for use with a gesture builder.
|
||||||
|
*/
|
||||||
|
export function forward(
|
||||||
|
target: OriginView,
|
||||||
|
options?: OriginNavigationActionOptions,
|
||||||
|
): OriginNavigationIntent;
|
||||||
|
export function forward(
|
||||||
|
target: OriginView,
|
||||||
|
choreography: OriginChoreography,
|
||||||
|
options?: OriginNavigationActionOptions,
|
||||||
|
): OriginAction;
|
||||||
|
export function forward(
|
||||||
|
target: OriginView,
|
||||||
|
choreographyOrOptions:
|
||||||
|
OriginChoreography | OriginNavigationActionOptions = {},
|
||||||
|
options: OriginNavigationActionOptions = {},
|
||||||
|
): OriginAction | OriginNavigationIntent {
|
||||||
|
if (isChoreography(choreographyOrOptions)) {
|
||||||
|
return originAction(target, choreographyOrOptions, {
|
||||||
|
placement: options.placement ?? "above",
|
||||||
|
history: "push",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
target,
|
||||||
|
placement: choreographyOrOptions.placement ?? "above",
|
||||||
|
history: "push",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a retained-history 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.
|
||||||
|
*
|
||||||
|
* The action intentionally has no target recipe. At execution time the scene
|
||||||
|
* resolves the origin's mounted `previousNodeKey`, reveals that exact instance,
|
||||||
|
* and removes the current instance only if the operation commits. Omit
|
||||||
|
* choreography inside gesture `.navigate()`; provide it for a programmatic
|
||||||
|
* action.
|
||||||
|
*
|
||||||
|
* @param options - Stacking options for an animation-free navigation intent.
|
||||||
|
* @returns An animation-free back intent for use with a gesture builder.
|
||||||
|
*/
|
||||||
|
export function back(
|
||||||
|
options?: OriginNavigationActionOptions,
|
||||||
|
): OriginNavigationIntent;
|
||||||
|
export function back(
|
||||||
|
choreography: OriginChoreography,
|
||||||
|
options?: OriginNavigationActionOptions,
|
||||||
|
): OriginAction;
|
||||||
|
export function back(
|
||||||
|
choreographyOrOptions:
|
||||||
|
OriginChoreography | OriginNavigationActionOptions = {},
|
||||||
|
options: OriginNavigationActionOptions = {},
|
||||||
|
): OriginAction | OriginNavigationIntent {
|
||||||
|
if (!isChoreography(choreographyOrOptions)) {
|
||||||
|
return {
|
||||||
|
placement: choreographyOrOptions.placement ?? "under",
|
||||||
|
history: "back",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
choreography: choreographyOrOptions,
|
||||||
|
placement: options.placement ?? "under",
|
||||||
|
history: "back",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an intent or action whose target is stacked above its origin.
|
||||||
|
*
|
||||||
|
* This helper controls stacking, not movement direction. The supplied
|
||||||
|
* choreography may move either component however the application chooses.
|
||||||
|
* Omitting choreography produces an intent for gesture `.navigate()`.
|
||||||
|
*
|
||||||
|
* @param target - View recipe to create.
|
||||||
|
* @param choreography - Visual routine controlling the operation.
|
||||||
|
* @param options - Optional history behavior.
|
||||||
|
*/
|
||||||
|
export function above(
|
||||||
|
target: OriginView,
|
||||||
|
options?: OriginPlacementActionOptions,
|
||||||
|
): OriginNavigationIntent;
|
||||||
|
export function above(
|
||||||
|
target: OriginView,
|
||||||
|
choreography: OriginChoreography,
|
||||||
|
options?: OriginPlacementActionOptions,
|
||||||
|
): OriginAction;
|
||||||
|
export function above(
|
||||||
|
target: OriginView,
|
||||||
|
choreographyOrOptions: OriginChoreography | OriginPlacementActionOptions = {},
|
||||||
|
options: OriginPlacementActionOptions = {},
|
||||||
|
): OriginAction | OriginNavigationIntent {
|
||||||
|
if (isChoreography(choreographyOrOptions)) {
|
||||||
|
return originAction(target, choreographyOrOptions, {
|
||||||
|
placement: "above",
|
||||||
|
history: options.history,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
target,
|
||||||
|
placement: "above",
|
||||||
|
history: choreographyOrOptions.history ?? "push",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an intent or action whose target is stacked underneath its origin.
|
||||||
|
*
|
||||||
|
* Commonly used for custom reveal effects, but it has no implicit history
|
||||||
|
* meaning. Prefer {@link back} for retained-history navigation. Omitting
|
||||||
|
* choreography produces an intent for gesture `.navigate()`.
|
||||||
|
*
|
||||||
|
* @param target - View recipe to create underneath the origin.
|
||||||
|
* @param choreography - Visual routine controlling the operation.
|
||||||
|
* @param options - Optional history behavior.
|
||||||
|
*/
|
||||||
|
export function under(
|
||||||
|
target: OriginView,
|
||||||
|
options?: OriginPlacementActionOptions,
|
||||||
|
): OriginNavigationIntent;
|
||||||
|
export function under(
|
||||||
|
target: OriginView,
|
||||||
|
choreography: OriginChoreography,
|
||||||
|
options?: OriginPlacementActionOptions,
|
||||||
|
): OriginAction;
|
||||||
|
export function under(
|
||||||
|
target: OriginView,
|
||||||
|
choreographyOrOptions: OriginChoreography | OriginPlacementActionOptions = {},
|
||||||
|
options: OriginPlacementActionOptions = {},
|
||||||
|
): OriginAction | OriginNavigationIntent {
|
||||||
|
if (isChoreography(choreographyOrOptions)) {
|
||||||
|
return originAction(target, choreographyOrOptions, {
|
||||||
|
placement: "under",
|
||||||
|
history: options.history,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
target,
|
||||||
|
placement: "under",
|
||||||
|
history: choreographyOrOptions.history ?? "push",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const percent = (value: number) => `${(value * 100).toFixed(4)}%`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native-style forward motion: the target enters above the source while the
|
||||||
|
* source recedes slightly. These presets are examples; applications can
|
||||||
|
* replace them with arbitrary `defineOriginChoreography()` callbacks.
|
||||||
|
*/
|
||||||
|
export const slideLeft = defineOriginChoreography({
|
||||||
|
name: "slide-left",
|
||||||
|
commitThreshold: 0.36,
|
||||||
|
commitVelocity: 0.6,
|
||||||
|
effects: ({ progress }): OriginEffectSet => ({
|
||||||
|
source: {
|
||||||
|
transform: `translate3d(${percent(progress * -0.24)}, 0, 0) scale(${1 - progress * 0.025})`,
|
||||||
|
opacity: 1 - progress * 0.16,
|
||||||
|
},
|
||||||
|
target: {
|
||||||
|
transform: `translate3d(${percent(1 - progress)}, 0, 0)`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Back motion reveals the target underneath the source. This is merely a
|
||||||
|
* visual routine; the {@link back} action selects retained-history behavior.
|
||||||
|
*/
|
||||||
|
export const slideRight = defineOriginChoreography({
|
||||||
|
name: "slide-right",
|
||||||
|
commitThreshold: 0.36,
|
||||||
|
commitVelocity: 0.6,
|
||||||
|
effects: ({ progress }): OriginEffectSet => ({
|
||||||
|
source: {
|
||||||
|
transform: `translate3d(${percent(progress)}, 0, 0)`,
|
||||||
|
},
|
||||||
|
target: {
|
||||||
|
transform: `translate3d(${percent(-0.24 + progress * 0.24)}, 0, 0) scale(${0.975 + progress * 0.025})`,
|
||||||
|
opacity: 0.84 + progress * 0.16,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-fade preset that fades the source out while fading the target in.
|
||||||
|
*
|
||||||
|
* The default release thresholds from the scene are used.
|
||||||
|
*/
|
||||||
|
export const fade = defineOriginChoreography({
|
||||||
|
name: "fade",
|
||||||
|
effects: ({ progress }): OriginEffectSet => ({
|
||||||
|
source: { opacity: 1 - progress },
|
||||||
|
target: { opacity: progress },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize one contribution before the compositor combines it with effects
|
||||||
|
* inherited from earlier origin frames.
|
||||||
|
*
|
||||||
|
* Most applications should return plain effects from a choreography and let
|
||||||
|
* the scene call this function. It is exported for custom compositors and
|
||||||
|
* diagnostics.
|
||||||
|
*
|
||||||
|
* @param effect - Optional effect to normalize.
|
||||||
|
* @param fallbackLayer - Relative layer contribution added to `effect.layer`.
|
||||||
|
* @returns A defined effect with a numeric layer.
|
||||||
|
*/
|
||||||
|
export function normalizedEffect(
|
||||||
|
effect: OriginEffect | undefined,
|
||||||
|
fallbackLayer = 0,
|
||||||
|
): OriginEffect {
|
||||||
|
return {
|
||||||
|
...effect,
|
||||||
|
layer: (effect?.layer ?? 0) + fallbackLayer,
|
||||||
|
};
|
||||||
|
}
|
||||||
542
packages/core-v2/src/scene.test.ts
Normal file
542
packages/core-v2/src/scene.test.ts
Normal file
@@ -0,0 +1,542 @@
|
|||||||
|
import {
|
||||||
|
createApp,
|
||||||
|
defineComponent,
|
||||||
|
h,
|
||||||
|
nextTick,
|
||||||
|
onMounted,
|
||||||
|
onUnmounted,
|
||||||
|
type Component,
|
||||||
|
} from "vue";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import OriginScene from "./components/OriginScene.vue";
|
||||||
|
import {
|
||||||
|
above,
|
||||||
|
back,
|
||||||
|
defineOriginChoreography,
|
||||||
|
forward,
|
||||||
|
replace,
|
||||||
|
under,
|
||||||
|
} from "./motion";
|
||||||
|
import { createOriginScene, originView } from "./scene";
|
||||||
|
|
||||||
|
const mountedApps: Array<ReturnType<typeof createApp>> = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const app of mountedApps.splice(0)) app.unmount();
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
function component(name: string): Component {
|
||||||
|
return defineComponent({
|
||||||
|
name,
|
||||||
|
render: () => h("div", { "data-test-view": name }, name),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const layeredMotion = defineOriginChoreography({
|
||||||
|
name: "test-layered-motion",
|
||||||
|
effects: ({ progress }) => ({
|
||||||
|
source: { transform: `translateX(${-progress * 100}px)` },
|
||||||
|
target: { transform: `translateX(${(1 - progress) * 100}px)` },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
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" });
|
||||||
|
const y = originView(component("Y"), undefined, { key: "y" });
|
||||||
|
const z = originView(component("Z"), undefined, { key: "z" });
|
||||||
|
const scene = createOriginScene({ initial: x });
|
||||||
|
const xKey = scene.nodes.value[0]!.key;
|
||||||
|
|
||||||
|
const xy = await scene.begin(xKey, above(y, layeredMotion));
|
||||||
|
xy.update(0.5);
|
||||||
|
const yz = await scene.begin(xy.targetKey, above(z, layeredMotion));
|
||||||
|
yz.update(0.25);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Y inherits the target half of X→Y, then adds its own source half of
|
||||||
|
* Y→Z. Z inherits X→Y as well, but receives Y→Z's target half.
|
||||||
|
*/
|
||||||
|
expect(scene.styleForNode(xy.targetKey).transform).toBe(
|
||||||
|
"translateX(50px) translateX(-25px)",
|
||||||
|
);
|
||||||
|
expect(scene.styleForNode(xy.targetKey)).toMatchObject({
|
||||||
|
position: "absolute",
|
||||||
|
inset: "0",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
});
|
||||||
|
expect(scene.styleForNode(yz.targetKey).transform).toBe(
|
||||||
|
"translateX(50px) translateX(75px)",
|
||||||
|
);
|
||||||
|
expect(scene.nodes.value.map((node) => node.view.name)).toEqual([
|
||||||
|
"X",
|
||||||
|
"Y",
|
||||||
|
"Z",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splices completed operations in either order without losing descendants", async () => {
|
||||||
|
const scene = createOriginScene({
|
||||||
|
initial: originView(component("X"), undefined, { key: "x" }),
|
||||||
|
});
|
||||||
|
const xKey = scene.nodes.value[0]!.key;
|
||||||
|
const xy = await scene.begin(
|
||||||
|
xKey,
|
||||||
|
above(originView(component("Y"), undefined, { key: "y" }), layeredMotion),
|
||||||
|
);
|
||||||
|
xy.update(0.8);
|
||||||
|
const yz = await scene.begin(
|
||||||
|
xy.targetKey,
|
||||||
|
above(originView(component("Z"), undefined, { key: "z" }), layeredMotion),
|
||||||
|
);
|
||||||
|
yz.update(0.6);
|
||||||
|
|
||||||
|
// Completing the newer edge first parks Y while Z replaces it as the
|
||||||
|
// visual target. Y remains mounted as Z's retained previous entry.
|
||||||
|
await yz.finish({ commit: true, animate: false });
|
||||||
|
expect(scene.nodes.value.map((node) => node.view.name)).toEqual([
|
||||||
|
"X",
|
||||||
|
"Y",
|
||||||
|
"Z",
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
scene.nodes.value.find((node) => node.view.name === "Y")?.state,
|
||||||
|
).toBe("parked");
|
||||||
|
expect(scene.operations.value).toHaveLength(1);
|
||||||
|
expect(scene.operations.value[0]?.targetKey).toBe(yz.targetKey);
|
||||||
|
expect(scene.styleForNode(yz.targetKey).transform).toMatch(
|
||||||
|
/^translateX\(19\.9.+px\)$/,
|
||||||
|
);
|
||||||
|
|
||||||
|
await xy.finish({ commit: true, animate: false });
|
||||||
|
expect(
|
||||||
|
scene.nodes.value.map((node) => [node.view.name, node.state]),
|
||||||
|
).toEqual([
|
||||||
|
["X", "parked"],
|
||||||
|
["Y", "parked"],
|
||||||
|
["Z", "active"],
|
||||||
|
]);
|
||||||
|
expect(scene.operations.value).toHaveLength(0);
|
||||||
|
expect(scene.styleForNode(yz.targetKey).transform).toBe("none");
|
||||||
|
expect(scene.styleForNode(xKey).visibility).toBe("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not remount a target when its incoming operation is collapsed", async () => {
|
||||||
|
let yMounts = 0;
|
||||||
|
const X = component("X");
|
||||||
|
const Y = defineComponent({
|
||||||
|
name: "Y",
|
||||||
|
setup() {
|
||||||
|
onMounted(() => {
|
||||||
|
yMounts += 1;
|
||||||
|
});
|
||||||
|
return () => h("div", "Y");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const Z = component("Z");
|
||||||
|
const scene = createOriginScene({
|
||||||
|
initial: originView(X, undefined, { key: "x" }),
|
||||||
|
});
|
||||||
|
const host = document.createElement("div");
|
||||||
|
document.body.append(host);
|
||||||
|
const app = createApp({
|
||||||
|
render: () => h(OriginScene, { scene }),
|
||||||
|
});
|
||||||
|
mountedApps.push(app);
|
||||||
|
app.mount(host);
|
||||||
|
expect(
|
||||||
|
(host.querySelector(".nvo-scene") as HTMLElement | null)?.style.position,
|
||||||
|
).toBe("relative");
|
||||||
|
|
||||||
|
const xy = await scene.begin(
|
||||||
|
scene.nodes.value[0]!.key,
|
||||||
|
above(originView(Y, undefined, { key: "y" }), layeredMotion),
|
||||||
|
);
|
||||||
|
const yz = await scene.begin(
|
||||||
|
xy.targetKey,
|
||||||
|
above(originView(Z, undefined, { key: "z" }), layeredMotion),
|
||||||
|
);
|
||||||
|
await nextTick();
|
||||||
|
expect(yMounts).toBe(1);
|
||||||
|
expect(
|
||||||
|
(host.querySelector('[data-origin-view="Y"]') as HTMLElement | null)
|
||||||
|
?.style.position,
|
||||||
|
).toBe("absolute");
|
||||||
|
|
||||||
|
// Y remains the same flat, keyed host while X→Y disappears around it.
|
||||||
|
await xy.finish({ commit: true, animate: false });
|
||||||
|
await nextTick();
|
||||||
|
expect(yMounts).toBe(1);
|
||||||
|
expect(scene.nodes.value.map((node) => node.view.name)).toEqual([
|
||||||
|
"X",
|
||||||
|
"Y",
|
||||||
|
"Z",
|
||||||
|
]);
|
||||||
|
|
||||||
|
await yz.cancel({ animate: false });
|
||||||
|
expect(
|
||||||
|
scene.nodes.value.map((node) => [node.view.name, node.state]),
|
||||||
|
).toEqual([
|
||||||
|
["X", "parked"],
|
||||||
|
["Y", "active"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reuses the retained previous node and pops only the current entry on back", async () => {
|
||||||
|
let xMounts = 0;
|
||||||
|
let xUnmounts = 0;
|
||||||
|
let yUnmounts = 0;
|
||||||
|
const X = defineComponent({
|
||||||
|
name: "X",
|
||||||
|
setup() {
|
||||||
|
onMounted(() => {
|
||||||
|
xMounts += 1;
|
||||||
|
});
|
||||||
|
onUnmounted(() => {
|
||||||
|
xUnmounts += 1;
|
||||||
|
});
|
||||||
|
return () =>
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
"data-scroll": "feed",
|
||||||
|
style: { height: "100px", overflow: "auto" },
|
||||||
|
},
|
||||||
|
h("div", { style: { height: "2000px" } }, "Feed"),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const Y = defineComponent({
|
||||||
|
name: "Y",
|
||||||
|
setup() {
|
||||||
|
onUnmounted(() => {
|
||||||
|
yUnmounts += 1;
|
||||||
|
});
|
||||||
|
return () => h("div", "Y");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const x = originView(X, { message: "original" }, { key: "x" });
|
||||||
|
const y = originView(Y, undefined, { key: "y" });
|
||||||
|
const scene = createOriginScene({ initial: x });
|
||||||
|
const host = document.createElement("div");
|
||||||
|
document.body.append(host);
|
||||||
|
const app = createApp({
|
||||||
|
render: () => h(OriginScene, { scene }),
|
||||||
|
});
|
||||||
|
mountedApps.push(app);
|
||||||
|
app.mount(host);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const xKey = scene.nodes.value[0]!.key;
|
||||||
|
const originalScroller = host.querySelector(
|
||||||
|
'[data-scroll="feed"]',
|
||||||
|
) as HTMLElement;
|
||||||
|
originalScroller.scrollTop = 842;
|
||||||
|
|
||||||
|
const xy = await scene.begin(xKey, forward(y, layeredMotion));
|
||||||
|
await xy.finish({ commit: true, animate: false });
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const yContext = scene.contextFor(xy.targetKey);
|
||||||
|
expect(yContext.previous).toBe(x);
|
||||||
|
expect(yContext.history).toEqual([x]);
|
||||||
|
expect(xMounts).toBe(1);
|
||||||
|
expect(xUnmounts).toBe(0);
|
||||||
|
expect(scene.nodes.value.find((node) => node.key === xKey)?.state).toBe(
|
||||||
|
"parked",
|
||||||
|
);
|
||||||
|
expect(host.querySelector('[data-scroll="feed"]')).toBe(originalScroller);
|
||||||
|
expect(originalScroller.scrollTop).toBe(842);
|
||||||
|
|
||||||
|
const yx = await scene.begin(xy.targetKey, back(layeredMotion));
|
||||||
|
expect(yx.targetKey).toBe(xKey);
|
||||||
|
await nextTick();
|
||||||
|
expect(host.querySelector('[data-scroll="feed"]')).toBe(originalScroller);
|
||||||
|
expect(originalScroller.scrollTop).toBe(842);
|
||||||
|
|
||||||
|
await yx.finish({ commit: true, animate: false });
|
||||||
|
await nextTick();
|
||||||
|
expect(scene.nodes.value.map((node) => node.key)).toEqual([xKey]);
|
||||||
|
expect(scene.contextFor(xKey).history).toEqual([]);
|
||||||
|
expect(scene.nodes.value[0]?.state).toBe("active");
|
||||||
|
expect(xMounts).toBe(1);
|
||||||
|
expect(xUnmounts).toBe(0);
|
||||||
|
expect(yUnmounts).toBe(1);
|
||||||
|
expect(originalScroller.scrollTop).toBe(842);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-parks the retained target when a back operation is cancelled", async () => {
|
||||||
|
const x = originView(component("X"), undefined, { key: "x" });
|
||||||
|
const y = originView(component("Y"), undefined, { key: "y" });
|
||||||
|
const scene = createOriginScene({ initial: x });
|
||||||
|
const xKey = scene.nodes.value[0]!.key;
|
||||||
|
const xy = await scene.begin(xKey, above(y, layeredMotion));
|
||||||
|
await xy.finish({ commit: true, animate: false });
|
||||||
|
|
||||||
|
const yx = await scene.begin(
|
||||||
|
xy.targetKey,
|
||||||
|
under(x, layeredMotion, { history: "back" }),
|
||||||
|
);
|
||||||
|
expect(yx.targetKey).toBe(xKey);
|
||||||
|
await yx.cancel({ animate: false });
|
||||||
|
|
||||||
|
expect(scene.operations.value).toHaveLength(0);
|
||||||
|
expect(
|
||||||
|
scene.nodes.value.map((node) => [node.view.name, node.state]),
|
||||||
|
).toEqual([
|
||||||
|
["X", "parked"],
|
||||||
|
["Y", "active"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("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" });
|
||||||
|
const scene = createOriginScene({ initial: x });
|
||||||
|
const xKey = scene.nodes.value[0]!.key;
|
||||||
|
const xy = await scene.begin(xKey, above(y, layeredMotion));
|
||||||
|
xy.update(0.8, 1);
|
||||||
|
|
||||||
|
const forwardSettlement = xy.finish({ commit: true });
|
||||||
|
const yx = await scene.begin(
|
||||||
|
xy.targetKey,
|
||||||
|
under(x, layeredMotion, { history: "back" }),
|
||||||
|
);
|
||||||
|
expect(yx.targetKey).toBe(xKey);
|
||||||
|
await yx.finish({ commit: true, animate: false });
|
||||||
|
await forwardSettlement;
|
||||||
|
|
||||||
|
expect(scene.operations.value).toHaveLength(0);
|
||||||
|
expect(scene.nodes.value.map((node) => [node.key, node.state])).toEqual([
|
||||||
|
[xKey, "active"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
772
packages/core-v2/src/scene.ts
Normal file
772
packages/core-v2/src/scene.ts
Normal file
@@ -0,0 +1,772 @@
|
|||||||
|
import {
|
||||||
|
computed,
|
||||||
|
markRaw,
|
||||||
|
nextTick,
|
||||||
|
reactive,
|
||||||
|
shallowReactive,
|
||||||
|
shallowRef,
|
||||||
|
type Component,
|
||||||
|
type CSSProperties,
|
||||||
|
} from "vue";
|
||||||
|
import { normalizedEffect } from "./motion";
|
||||||
|
import type {
|
||||||
|
MutableOriginOperation,
|
||||||
|
OriginAction,
|
||||||
|
OriginChoreographyContext,
|
||||||
|
OriginContext,
|
||||||
|
OriginEffect,
|
||||||
|
OriginEffectSet,
|
||||||
|
OriginFinishOptions,
|
||||||
|
OriginOperation,
|
||||||
|
OriginOperationHandle,
|
||||||
|
OriginRect,
|
||||||
|
OriginScene,
|
||||||
|
OriginSceneNode,
|
||||||
|
OriginSceneNodeState,
|
||||||
|
OriginView,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
interface SceneNodeState {
|
||||||
|
key: string;
|
||||||
|
sequence: number;
|
||||||
|
view: OriginView;
|
||||||
|
previousNodeKey?: string;
|
||||||
|
state: OriginSceneNodeState;
|
||||||
|
incomingOperationId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options used to create an independent origin scene. */
|
||||||
|
export interface CreateOriginSceneOptions {
|
||||||
|
/**
|
||||||
|
* One initial root recipe, or several independent roots rendered in the same
|
||||||
|
* flat scene. Most applications begin with one root.
|
||||||
|
*/
|
||||||
|
initial: OriginView | readonly OriginView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Optional developer-facing metadata for an {@link OriginView} recipe. */
|
||||||
|
export interface OriginViewOptions {
|
||||||
|
/**
|
||||||
|
* Stable recipe identity used in diagnostics and generated node-key prefixes.
|
||||||
|
* It does not preserve or reuse a mounted Vue component instance.
|
||||||
|
*/
|
||||||
|
key?: string;
|
||||||
|
/** Human-readable label used by inspectors and DOM data attributes. */
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let viewSequence = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn a component and props into a lightweight, reusable view recipe.
|
||||||
|
*
|
||||||
|
* Components are marked raw so Vue never attempts to proxy their definitions
|
||||||
|
* when recipes are placed in reactive scene/history structures.
|
||||||
|
*
|
||||||
|
* @typeParam Props - Props passed to the component when the recipe is mounted.
|
||||||
|
* @param component - Vue component definition to mount.
|
||||||
|
* @param props - Props captured by the recipe.
|
||||||
|
* @param options - Optional recipe identity and diagnostic name.
|
||||||
|
* @returns An immutable, reusable component recipe. It is not a Vue instance.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const profile = originView(
|
||||||
|
* ProfileView,
|
||||||
|
* { userId: "42" },
|
||||||
|
* { key: "profile-42", name: "Profile" },
|
||||||
|
* );
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function originView<
|
||||||
|
Props extends Record<string, unknown> = Record<string, unknown>,
|
||||||
|
>(
|
||||||
|
component: Component,
|
||||||
|
props?: Props,
|
||||||
|
options: OriginViewOptions = {},
|
||||||
|
): OriginView<Props> {
|
||||||
|
const inferredName =
|
||||||
|
options.name ??
|
||||||
|
(typeof component === "object" && "name" in component
|
||||||
|
? String(component.name)
|
||||||
|
: undefined);
|
||||||
|
return markRaw({
|
||||||
|
component: markRaw(component),
|
||||||
|
props,
|
||||||
|
key: options.key ?? `${inferredName ?? "view"}-${++viewSequence}`,
|
||||||
|
name: inferredName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number) {
|
||||||
|
return Math.max(0, Math.min(1, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function elementRect(element: HTMLElement | null | undefined) {
|
||||||
|
if (!element) return undefined;
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
top: rect.top,
|
||||||
|
left: rect.left,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultViewport(element: HTMLElement | null): OriginRect {
|
||||||
|
return (
|
||||||
|
elementRect(element) ?? {
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: typeof window === "undefined" ? 1 : window.innerWidth,
|
||||||
|
height: typeof window === "undefined" ? 1 : window.innerHeight,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prefersReducedMotion() {
|
||||||
|
return (
|
||||||
|
typeof window === "undefined" ||
|
||||||
|
!window.requestAnimationFrame ||
|
||||||
|
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create one independent scene/history context.
|
||||||
|
*
|
||||||
|
* The implementation stores nodes flat. Operation edges describe how their
|
||||||
|
* visual effects compose, but a Vue component never moves to a different VNode
|
||||||
|
* parent as edges are added or removed. This is what preserves component state
|
||||||
|
* while X→Y and Y→Z overlap.
|
||||||
|
*
|
||||||
|
* @param options - Initial root view recipe or recipes.
|
||||||
|
* @returns A self-contained reactive scene. Render it through `OriginScene`.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const scene = createOriginScene({
|
||||||
|
* initial: originView(HomeView, undefined, { key: "home" }),
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function createOriginScene(
|
||||||
|
options: CreateOriginSceneOptions,
|
||||||
|
): OriginScene {
|
||||||
|
const nodes = shallowReactive(new Map<string, SceneNodeState>());
|
||||||
|
const operations = shallowReactive(new Map<number, MutableOriginOperation>());
|
||||||
|
/*
|
||||||
|
* A close gesture temporarily removes a connected resting edge to avoid a
|
||||||
|
* visual cycle (page→drawer→page). If that close is cancelled, this map lets
|
||||||
|
* us restore the exact same edge and component instances.
|
||||||
|
*/
|
||||||
|
const suspendedPresentations = new Map<number, MutableOriginOperation>();
|
||||||
|
const roots = shallowRef<string[]>([]);
|
||||||
|
const elements = new Map<string, HTMLElement>();
|
||||||
|
let container: HTMLElement | null = null;
|
||||||
|
let nodeSequence = 0;
|
||||||
|
let operationSequence = 0;
|
||||||
|
|
||||||
|
function uniqueNodeKey(view: OriginView) {
|
||||||
|
return `${view.key ?? view.name ?? "view"}::${++nodeSequence}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addNode(
|
||||||
|
view: OriginView,
|
||||||
|
previousNodeKey?: string,
|
||||||
|
incomingOperationId?: number,
|
||||||
|
) {
|
||||||
|
const node: SceneNodeState = shallowReactive({
|
||||||
|
key: uniqueNodeKey(view),
|
||||||
|
sequence: nodeSequence,
|
||||||
|
view: markRaw(view),
|
||||||
|
previousNodeKey,
|
||||||
|
state: incomingOperationId ? "transitioning" : "active",
|
||||||
|
incomingOperationId,
|
||||||
|
});
|
||||||
|
nodes.set(node.key, node);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialViews = Array.isArray(options.initial)
|
||||||
|
? options.initial
|
||||||
|
: [options.initial];
|
||||||
|
for (const initial of initialViews) {
|
||||||
|
const node = addNode(initial);
|
||||||
|
roots.value = [...roots.value, node.key];
|
||||||
|
}
|
||||||
|
|
||||||
|
function historyNodesFor(node: SceneNodeState) {
|
||||||
|
const history: SceneNodeState[] = [];
|
||||||
|
const visited = new Set<string>([node.key]);
|
||||||
|
let previousKey = node.previousNodeKey;
|
||||||
|
while (previousKey && !visited.has(previousKey)) {
|
||||||
|
visited.add(previousKey);
|
||||||
|
const previous = nodes.get(previousKey);
|
||||||
|
if (!previous) break;
|
||||||
|
history.unshift(previous);
|
||||||
|
previousKey = previous.previousNodeKey;
|
||||||
|
}
|
||||||
|
return history;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextFor(nodeKey: string): OriginContext {
|
||||||
|
const node = nodes.get(nodeKey);
|
||||||
|
if (!node)
|
||||||
|
throw new Error(`Origin scene node "${nodeKey}" no longer exists.`);
|
||||||
|
const historyNodes = historyNodesFor(node);
|
||||||
|
return {
|
||||||
|
nodeKey,
|
||||||
|
view: node.view,
|
||||||
|
canGoBack: Boolean(node.previousNodeKey),
|
||||||
|
previous: historyNodes.at(-1)?.view,
|
||||||
|
history: historyNodes.map((entry) => entry.view),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function previousNodeForAction(origin: SceneNodeState, action: OriginAction) {
|
||||||
|
switch (action.history ?? "push") {
|
||||||
|
case "push":
|
||||||
|
return origin.key;
|
||||||
|
case "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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function operationContext(
|
||||||
|
operation: MutableOriginOperation,
|
||||||
|
): OriginChoreographyContext {
|
||||||
|
return {
|
||||||
|
progress: operation.progress,
|
||||||
|
velocity: operation.velocity,
|
||||||
|
phase: operation.phase,
|
||||||
|
intent: operation.intent,
|
||||||
|
originRect: operation.originRect,
|
||||||
|
targetRect: operation.targetRect,
|
||||||
|
viewport: defaultViewport(container),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function effectSetFor(operation: MutableOriginOperation): OriginEffectSet {
|
||||||
|
return operation.choreography.effects(operationContext(operation));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the origin-frame effects inherited by a node. The source effect of
|
||||||
|
* an ancestor is intentionally excluded: descendants inherit the target side
|
||||||
|
* of an operation, while only the initiating component receives its source
|
||||||
|
* side.
|
||||||
|
*/
|
||||||
|
function inheritedEffects(
|
||||||
|
nodeKey: string,
|
||||||
|
visited = new Set<string>(),
|
||||||
|
): OriginEffect[] {
|
||||||
|
if (visited.has(nodeKey)) return [];
|
||||||
|
visited.add(nodeKey);
|
||||||
|
|
||||||
|
const node = nodes.get(nodeKey);
|
||||||
|
const incoming = node?.incomingOperationId
|
||||||
|
? operations.get(node.incomingOperationId)
|
||||||
|
: undefined;
|
||||||
|
if (!node || !incoming) return [];
|
||||||
|
|
||||||
|
const effects = effectSetFor(incoming);
|
||||||
|
const targetLayer = incoming.placement === "above" ? 1 : -1;
|
||||||
|
return [
|
||||||
|
...inheritedEffects(incoming.originKey, visited),
|
||||||
|
normalizedEffect(effects.frame),
|
||||||
|
normalizedEffect(effects.target, targetLayer),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function visualEffects(nodeKey: string) {
|
||||||
|
const result = inheritedEffects(nodeKey);
|
||||||
|
const outgoing = visualOutgoingFor(nodeKey);
|
||||||
|
if (!outgoing) return result;
|
||||||
|
const effects = effectSetFor(outgoing);
|
||||||
|
return [
|
||||||
|
...result,
|
||||||
|
normalizedEffect(effects.frame),
|
||||||
|
normalizedEffect(effects.source),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
function styleForNode(nodeKey: string): CSSProperties {
|
||||||
|
const node = nodes.get(nodeKey);
|
||||||
|
if (!node) return {};
|
||||||
|
|
||||||
|
const transforms: string[] = [];
|
||||||
|
let opacity = 1;
|
||||||
|
let layer = 0;
|
||||||
|
/*
|
||||||
|
* These rules are compositor invariants rather than visual theming. Keep
|
||||||
|
* them inline so a missing optional package stylesheet can never place
|
||||||
|
* scene nodes back into normal block/flex flow and vertically stack views.
|
||||||
|
*/
|
||||||
|
const style: CSSProperties = {
|
||||||
|
position: "absolute",
|
||||||
|
inset: "0",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
overflow: "hidden",
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const effect of visualEffects(nodeKey)) {
|
||||||
|
if (effect.transform && effect.transform !== "none")
|
||||||
|
transforms.push(effect.transform);
|
||||||
|
if (effect.opacity !== undefined) opacity *= effect.opacity;
|
||||||
|
layer += effect.layer ?? 0;
|
||||||
|
|
||||||
|
if (effect.style) {
|
||||||
|
const effectStyle = effect.style;
|
||||||
|
Object.assign(style, effectStyle);
|
||||||
|
if (
|
||||||
|
typeof effectStyle.transform === "string" &&
|
||||||
|
effectStyle.transform !== "none"
|
||||||
|
)
|
||||||
|
transforms.push(effectStyle.transform);
|
||||||
|
if (effectStyle.opacity !== undefined) {
|
||||||
|
const numericOpacity = Number(effectStyle.opacity);
|
||||||
|
if (Number.isFinite(numericOpacity)) opacity *= numericOpacity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicitly assign the composited properties after Object.assign so an
|
||||||
|
// individual effect cannot accidentally replace inherited transform work.
|
||||||
|
style.transform = transforms.length ? transforms.join(" ") : "none";
|
||||||
|
style.opacity = String(clamp(opacity));
|
||||||
|
style.zIndex = 1_000_000 + layer * 10_000 + node.sequence;
|
||||||
|
style.pointerEvents = isNodeInteractive(nodeKey) ? "auto" : "none";
|
||||||
|
if (node.state === "parked") {
|
||||||
|
// Parked entries remain mounted in their original flat hosts. Keeping
|
||||||
|
// the DOM preserves local component state and nested scroll positions,
|
||||||
|
// while visibility/inert handling removes them from presentation.
|
||||||
|
style.visibility = "hidden";
|
||||||
|
style.contentVisibility = "hidden";
|
||||||
|
}
|
||||||
|
return style;
|
||||||
|
}
|
||||||
|
|
||||||
|
function outgoingFor(nodeKey: string) {
|
||||||
|
return [...operations.values()].find(
|
||||||
|
(operation) =>
|
||||||
|
operation.originKey === nodeKey && operation.phase !== "finished",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNodeInteractive(nodeKey: string) {
|
||||||
|
const node = nodes.get(nodeKey);
|
||||||
|
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
|
||||||
|
// beneath/above it. This is node-local fate, not a global "active view".
|
||||||
|
const incoming = node.incomingOperationId
|
||||||
|
? operations.get(node.incomingOperationId)
|
||||||
|
: undefined;
|
||||||
|
if (incoming?.intent === "cancel") return false;
|
||||||
|
return outgoingFor(nodeKey)?.intent !== "commit";
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateOperation(id: number, progress: number, velocity = 0) {
|
||||||
|
const operation = operations.get(id);
|
||||||
|
if (!operation || operation.phase === "finished") return;
|
||||||
|
operation.progress = clamp(progress);
|
||||||
|
operation.velocity = velocity;
|
||||||
|
if (operation.phase === "preparing") return;
|
||||||
|
operation.phase = "interactive";
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeNode(nodeKey: string) {
|
||||||
|
elements.delete(nodeKey);
|
||||||
|
nodes.delete(nodeKey);
|
||||||
|
roots.value = roots.value.filter((key) => key !== nodeKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a newly created forward branch while leaving the origin and all
|
||||||
|
* earlier retained entries untouched.
|
||||||
|
*/
|
||||||
|
function removeRetainedBranch(nodeKey: string, visited = new Set<string>()) {
|
||||||
|
if (visited.has(nodeKey)) return;
|
||||||
|
visited.add(nodeKey);
|
||||||
|
|
||||||
|
for (const node of [...nodes.values()]) {
|
||||||
|
if (node.previousNodeKey === nodeKey)
|
||||||
|
removeRetainedBranch(node.key, visited);
|
||||||
|
}
|
||||||
|
for (const operation of [...operations.values()]) {
|
||||||
|
if (
|
||||||
|
operation.originKey === nodeKey ||
|
||||||
|
operation.targetKey === nodeKey ||
|
||||||
|
operation.entryTargetKey === nodeKey
|
||||||
|
)
|
||||||
|
operations.delete(operation.id);
|
||||||
|
}
|
||||||
|
removeNode(nodeKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshVisibleState(node: SceneNodeState) {
|
||||||
|
if (node.state === "parked" || node.state === "exposed") return;
|
||||||
|
const incoming = node.incomingOperationId
|
||||||
|
? operations.get(node.incomingOperationId)
|
||||||
|
: undefined;
|
||||||
|
node.state =
|
||||||
|
(incoming && incoming.phase !== "finished") || outgoingFor(node.key)
|
||||||
|
? "transitioning"
|
||||||
|
: "active";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transfer an operation's visual graph position to its target without moving
|
||||||
|
* either Vue VNode. The history chain is intentionally independent from this
|
||||||
|
* temporary coordinate graph.
|
||||||
|
*/
|
||||||
|
function spliceVisualTarget(origin: SceneNodeState, target: SceneNodeState) {
|
||||||
|
const parentOperation = origin.incomingOperationId
|
||||||
|
? operations.get(origin.incomingOperationId)
|
||||||
|
: undefined;
|
||||||
|
if (parentOperation) {
|
||||||
|
parentOperation.targetKey = target.key;
|
||||||
|
target.incomingOperationId = parentOperation.id;
|
||||||
|
} else {
|
||||||
|
roots.value = roots.value.map((key) =>
|
||||||
|
key === origin.key ? target.key : key,
|
||||||
|
);
|
||||||
|
target.incomingOperationId = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalize a committed operation.
|
||||||
|
*
|
||||||
|
* 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);
|
||||||
|
if (!operation) return false;
|
||||||
|
const origin = nodes.get(operation.originKey);
|
||||||
|
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 {
|
||||||
|
// Back pops the current entry; replace discards it in favor of the new
|
||||||
|
// target. Both preserve every earlier retained instance.
|
||||||
|
removeNode(origin.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
target.state = "active";
|
||||||
|
refreshVisibleState(target);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelOperation(id: number) {
|
||||||
|
const operation = operations.get(id);
|
||||||
|
if (!operation) return;
|
||||||
|
const origin = nodes.get(operation.originKey);
|
||||||
|
const target = nodes.get(operation.entryTargetKey);
|
||||||
|
const suspendedPresentation = suspendedPresentations.get(id);
|
||||||
|
operation.phase = "finished";
|
||||||
|
operations.delete(id);
|
||||||
|
suspendedPresentations.delete(id);
|
||||||
|
|
||||||
|
if (operation.history === "back") {
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
removeRetainedBranch(operation.entryTargetKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (origin) {
|
||||||
|
origin.state = "active";
|
||||||
|
refreshVisibleState(origin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settle(id: number, targetProgress: 0 | 1, animate: boolean) {
|
||||||
|
const operation = operations.get(id);
|
||||||
|
if (!operation) return;
|
||||||
|
if (!animate || prefersReducedMotion()) {
|
||||||
|
operation.progress = targetProgress;
|
||||||
|
operation.velocity = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
let position = operation.progress;
|
||||||
|
let velocity = Math.max(-8, Math.min(8, operation.velocity));
|
||||||
|
let previous = performance.now();
|
||||||
|
|
||||||
|
const frame = (time: number) => {
|
||||||
|
const live = operations.get(id);
|
||||||
|
if (!live) return resolve();
|
||||||
|
|
||||||
|
// A damped spring makes release velocity continuous with pointer motion
|
||||||
|
// without imposing a fixed-duration easing on custom choreographies.
|
||||||
|
const elapsed = Math.min(
|
||||||
|
0.032,
|
||||||
|
Math.max(0.001, (time - previous) / 1000),
|
||||||
|
);
|
||||||
|
previous = time;
|
||||||
|
const iterations = Math.max(1, Math.ceil(elapsed / (1 / 120)));
|
||||||
|
const dt = elapsed / iterations;
|
||||||
|
for (let index = 0; index < iterations; index += 1) {
|
||||||
|
const acceleration =
|
||||||
|
(targetProgress - position) * 280 - velocity * 30;
|
||||||
|
velocity += acceleration * dt;
|
||||||
|
position += velocity * dt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const done =
|
||||||
|
Math.abs(targetProgress - position) < 0.002 &&
|
||||||
|
Math.abs(velocity) < 0.02;
|
||||||
|
live.progress = done ? targetProgress : clamp(position);
|
||||||
|
live.velocity = done ? 0 : velocity;
|
||||||
|
if (done) resolve();
|
||||||
|
else window.requestAnimationFrame(frame);
|
||||||
|
};
|
||||||
|
window.requestAnimationFrame(frame);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finishOperation(
|
||||||
|
id: number,
|
||||||
|
options: OriginFinishOptions = {},
|
||||||
|
) {
|
||||||
|
const operation = operations.get(id);
|
||||||
|
if (!operation) return false;
|
||||||
|
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.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.
|
||||||
|
operation.intent = shouldCommit ? "commit" : "cancel";
|
||||||
|
operation.phase = "settling";
|
||||||
|
await settle(id, shouldCommit ? 1 : 0, options.animate ?? true);
|
||||||
|
|
||||||
|
if (!operations.has(id)) return shouldCommit;
|
||||||
|
if (shouldCommit) return commitOperation(id);
|
||||||
|
cancelOperation(id);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function begin(
|
||||||
|
originKey: string,
|
||||||
|
action: OriginAction,
|
||||||
|
): Promise<OriginOperationHandle> {
|
||||||
|
const origin = nodes.get(originKey);
|
||||||
|
if (!origin)
|
||||||
|
throw new Error(`Cannot animate from missing origin "${originKey}".`);
|
||||||
|
if (origin.state === "parked" || 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. ` +
|
||||||
|
"A descendant target may start its own operation instead.",
|
||||||
|
);
|
||||||
|
|
||||||
|
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;
|
||||||
|
if (!previousKey)
|
||||||
|
throw new Error(
|
||||||
|
`Cannot go back from origin "${originKey}" without a previous entry.`,
|
||||||
|
);
|
||||||
|
const retainedTarget = nodes.get(previousKey);
|
||||||
|
if (!retainedTarget)
|
||||||
|
throw new Error(
|
||||||
|
`Cannot go back to missing retained entry "${previousKey}".`,
|
||||||
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* If back starts while the immediately preceding forward spring is still
|
||||||
|
* settling, collapse that already-committed edge first. Creating a new
|
||||||
|
* edge back to its origin would otherwise form X→Y→X. Reciprocal
|
||||||
|
* choreographies begin at the same visual endpoints, so this handoff is
|
||||||
|
* continuous without remounting either node.
|
||||||
|
*/
|
||||||
|
const incoming = origin.incomingOperationId
|
||||||
|
? operations.get(origin.incomingOperationId)
|
||||||
|
: undefined;
|
||||||
|
if (incoming?.originKey === retainedTarget.key) {
|
||||||
|
if (incoming.intent !== "commit")
|
||||||
|
throw new Error(
|
||||||
|
"Cannot go back through an undecided forward operation.",
|
||||||
|
);
|
||||||
|
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))
|
||||||
|
throw new Error(
|
||||||
|
`Retained target "${retainedTarget.key}" already has an outgoing operation.`,
|
||||||
|
);
|
||||||
|
if (retainedTarget.incomingOperationId)
|
||||||
|
throw new Error(
|
||||||
|
`Retained target "${retainedTarget.key}" is already transitioning.`,
|
||||||
|
);
|
||||||
|
target = retainedTarget;
|
||||||
|
} else {
|
||||||
|
if (!action.target)
|
||||||
|
throw new Error(
|
||||||
|
`A "${history}" operation requires a target view recipe.`,
|
||||||
|
);
|
||||||
|
target = addNode(action.target, previousNodeForAction(origin, action));
|
||||||
|
}
|
||||||
|
|
||||||
|
const operationId = ++operationSequence;
|
||||||
|
target.incomingOperationId = operationId;
|
||||||
|
target.state = "transitioning";
|
||||||
|
origin.state = "transitioning";
|
||||||
|
const operation = reactive<MutableOriginOperation>({
|
||||||
|
id: operationId,
|
||||||
|
originKey,
|
||||||
|
targetKey: target.key,
|
||||||
|
entryTargetKey: target.key,
|
||||||
|
choreography: markRaw(action.choreography),
|
||||||
|
placement: action.placement ?? "above",
|
||||||
|
history,
|
||||||
|
progress: 0,
|
||||||
|
velocity: 0,
|
||||||
|
phase: "preparing",
|
||||||
|
intent: "undecided",
|
||||||
|
originRect: elementRect(elements.get(originKey)),
|
||||||
|
});
|
||||||
|
operations.set(operation.id, operation);
|
||||||
|
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.
|
||||||
|
await nextTick();
|
||||||
|
operation.targetRect = elementRect(elements.get(target.key));
|
||||||
|
operation.phase = "interactive";
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: operation.id,
|
||||||
|
originKey,
|
||||||
|
targetKey: target.key,
|
||||||
|
update: (progress, velocity) =>
|
||||||
|
updateOperation(operation.id, progress, velocity),
|
||||||
|
finish: (finishOptions) => finishOperation(operation.id, finishOptions),
|
||||||
|
cancel: async (cancelOptions) => {
|
||||||
|
await finishOperation(operation.id, {
|
||||||
|
commit: false,
|
||||||
|
animate: cancelOptions?.animate,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function perform(originKey: string, action: OriginAction) {
|
||||||
|
const operation = await begin(originKey, action);
|
||||||
|
return operation.finish({ commit: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes: computed(
|
||||||
|
() =>
|
||||||
|
[...nodes.values()].map((node) => ({
|
||||||
|
...node,
|
||||||
|
history: historyNodesFor(node).map((entry) => entry.view),
|
||||||
|
})) as readonly OriginSceneNode[],
|
||||||
|
),
|
||||||
|
operations: computed(
|
||||||
|
() =>
|
||||||
|
[...operations.values()].filter(
|
||||||
|
(operation) => operation.phase !== "finished",
|
||||||
|
) as readonly OriginOperation[],
|
||||||
|
),
|
||||||
|
roots,
|
||||||
|
contextFor,
|
||||||
|
begin,
|
||||||
|
perform,
|
||||||
|
registerElement(nodeKey, element) {
|
||||||
|
if (element) elements.set(nodeKey, element);
|
||||||
|
else elements.delete(nodeKey);
|
||||||
|
},
|
||||||
|
registerContainer(element) {
|
||||||
|
container = element;
|
||||||
|
},
|
||||||
|
styleForNode,
|
||||||
|
isNodeInteractive,
|
||||||
|
};
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user