Author SHA1 Message Date
Claude caf2ad692a Restore native scrolling and reliable recognition; add feel tests
The previous round regressed the demo against main: dropped frames while
a finger drags, and swipes ignored when they start imperfectly.

Frame drops: hosts used touch-action: manipulation plus a blocking
touchmove listener, so Chrome waited for JavaScript on every touch move
(~30% of frames over 20ms in a drag, p95 28ms). Hosts again use per-axis
touch-action like main (pan-y for horizontal gestures, and so on), and
attach no touch listener.

Flaky recognition: an irreversible decision after 6px dropped swipes
with a wobbly or backward start. Recognition again measures from the
pointer-down point until the pointer lifts, as on main.

Scrolling beside gestures, natively:
- .from.scrollEdge() uses direction-specific touch-action while its
  scroller is at the edge (pan-x pan-down for drag-down at the top), the
  pull-to-refresh pattern from the Pointer Events spec. A passive scroll
  listener keeps it current; no touch handling blocks the browser.
- Where directional values are unsupported (Safari), scroll-edge
  gestures alone fall back to claiming touches at the browser's first
  cancelable touchmove.
- Hosts publish touch-action as --nvo-touch-action and the stylesheet
  applies it to descendants, so nested scroll containers keep it and
  horizontal swipes over lists are not cancelled. Ignored subtrees get
  touch-action: auto back.
- Bindings gain listeners and ref (spread on the host); the components
  use them.

Feel tests (npm run test:feel): Chromium with emulated touch checks
recognition on ten human-like traces, frame pacing budgets while
dragging (p95 <= 20ms, <= 5% over 20ms) for a swipe and a scroll-edge
dismiss, and the behaviour checks. Against the previous code they fail
on exactly the reported regressions; now 23/23 pass. On the demo, all
traces match main and drag frame pacing matches or beats it.

Commit thresholds are unchanged (0.25 distance, 0.2 flick).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
2026-11-03 05:56:26 +00:00
Claude 6889fc0112 Make gestures cooperate with scrolling and feel smoother
From an app built on core-v2:

Scrolling (#3, #4, #14)
- Each recognizer locks onto a touch within its first 6px: along its axis
  and direction it holds the touch, otherwise it lets go. A new
  onTouchmove binding cancels native scrolling only while a recognizer
  holds the touch, so hosts use touch-action: manipulation and one
  vertical gesture no longer disables vertical scrolling. Several
  gestures on one host share touches through the same rule.
- .from.scrollEdge(scroller, edge) admits drag-to-dismiss only while a
  scroller is at the edge (#5).

Feel
- recognition.distance sets what counts as full progress: pixels, a CSS
  length, or a measuring function (#6).
- Choreographies receive overshoot, the drag past either end of the
  clamped progress, and the settling spring starts from it (#7).
- Velocity is the least-squares trend of the last 100ms (#8).
- Defaults retuned to commitThreshold 0.25 and commitVelocity 0.2; the
  slide presets use them (#9).
- Choreographies may set spring stiffness and damping (#10).

Hooks and helpers
- .onArm()/.onDisarm() run during a drag when releasing would start or
  stop committing (#12).
- startedIn, nearEdge, atScrollEdge, anyOf, allOf start predicates (#15).
- Development builds warn when an operation's first frame moves a node
  that was visibly at rest, the one-frame "pop" of mismatched open and
  close choreographies (#16).

Docs: scrolling, ignored elements, animation performance, haptics, and
operating-system edge gestures including CloseWatcher/popstate for PWAs
(#11, #17, #18).

Verification: a Chromium harness with emulated touch (verify/browser)
checks swipes over scrollers, native scrolling beside gestures,
scroll-edge dismissal, onCommit inside pointerup, and the continuity
warning. Reverting the earlier capture fix makes it fail exactly as
reported ("none of the handles work").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
2026-10-31 08:22:11 +00:00
Claude d4d7feb3b7 Fix touch gestures cancelling, backward flicks, and late onCommit
- Only the host's own lostpointercapture cancels a gesture. Taking
  capture makes the touched child lose its implicit touch capture, and
  that bubbling event was cancelling every touch gesture immediately.
- A flick at least commitVelocity fast now decides by its direction:
  backward cancels even past the distance threshold, forward commits.
  Slower releases still decide by distance.
- Pointer-up finishes an already-begun operation without awaiting, so
  onCommit runs inside the pointer-up event, where iOS still allows
  haptics, audio, and clipboard writes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
2026-10-31 05:41:51 +00:00
Claude 7033814550 Trim per-release overhead in the scene
Only re-measure the viewport at begin() when a container is mounted, and
only allocate waiting machinery when an interrupted finish() actually
needs to wait for a later release. Interrupted releases now resolve with
the final decided outcome in every case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
2026-10-16 04:28:43 +00:00
Claude a46c411ec4 Move core-v2 tests into packages/core-v2/tests
Tests no longer sit next to the implementation. They import the package
through ../src, the root tsconfig still type-checks them, and the
package build and declarations cover only src/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
2026-10-16 04:24:47 +00:00
Claude 1924d4b52b Close a revealed page's open drawer when its back is cancelled
discardOutgoing skipped finished resting edges, so a persistAtRest drawer
opened by a node revealed during a back operation stayed attached after
that back was cancelled and the node returned to history. Found by the
property tests (1000-seed run).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
2026-10-16 04:10:53 +00:00
Claude b6f782d8af Fix stuck gestures and scene graph races; honour commitVelocity
Gestures
- Cancel a captured gesture on lostpointercapture, on a new primary
  pointer-down, and when its component unmounts, so operations are
  never left stuck mid-transition. Bindings gain onLostpointercapture.
- Warn (console.warn) when navigate() fails or the scene rejects an
  operation; stay quiet when two recognizers race for one origin.
- OriginGesture and OriginGestureSurface props are reactive. Changes
  apply from the next pointer-down; recognizers are kept per list
  position so re-renders never interrupt a drag.
- Remove the legacy direction/edge/threshold/action gesture form
  (OriginGestureOptions, legacy OriginGesture props, MaybeOriginAction).

Scene
- Honour choreography commitVelocity (default 0.3, as before).
- Scene errors are OriginSceneError with a stable code.
- update() during settling now really catches an operation: the spring
  stops, and only the latest finish() decides the outcome.
- Fix graph corruption found by the new property tests, all present on
  main: replacements escaping a cancelled push, edges left dangling when
  their current target differs from the original one, revealed targets
  keeping operations after their back is cancelled, history pointing at
  removed entries after replace, visual cycles from back through an
  undecided replace, exposed nodes reactivated as active, and a cancel
  cascade that could empty the whole scene.

Tests
- Add property tests checking structural invariants after every step of
  random operation sequences and pointer streams (PROPERTY_SEEDS).
- Add recognizer tests for stuck gestures, warnings, reactive props,
  and commitVelocity. Drop the main-comparison suites; keep benchmarks.

Also fix the origins demo's two pre-existing type errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
2026-10-16 04:09:57 +00:00
Claude 46f030630b Speed up compositing and gesture start without behavior changes
- Memoize each operation's choreography effect set in a computed, so
  nodes sharing an edge stop re-evaluating it (linear, not quadratic,
  in chain depth).
- Cache the scene viewport and refresh it from a ResizeObserver and at
  each begin(), instead of measuring the container per node per frame.
- Index operations by origin key so outgoing lookups no longer scan
  every operation; all map changes go through attach()/detach().
- Resolve px and % gesture edge distances arithmetically; only other
  CSS lengths use the off-screen probe. Verified in Chromium to match
  the probe within its 1/64px layout rounding.

The differential suites against main still match exactly. Benchmarks
vs main: styleForNode 2.0x (3 live ops) and 2.4x (10 live ops),
rendered frames 1.14x.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
2026-10-16 03:26:51 +00:00
Claude 8448e910ed Add .onCommit() to the gesture builder
`.onCommit(handler)` is available at every builder stage after the
direction, returns the same stage, and accumulates handlers in order.
Handlers run once, synchronously at release, when the operation commits
and before the settling animation, with the same context `.complete()`
receives. A throwing handler is reported without stopping later handlers
or the operation. Intended for haptic feedback such as
`navigator.vibrate`.

Named onCommit rather than then so builder stages never become
thenables. The builder now threads one frozen draft through its stages.

OriginOperationHandle gains a read-only `intent`, which the recognizer
reads right after finish() to know whether the release committed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
2026-10-16 03:08:12 +00:00
Claude 511e58c198 Consolidate core-v2 modules and move scene logic onto classes
- Merge the small modules into 12 files: shared, choreography,
  navigation, scene/{types,graph,scene}, gesture/{definition,recognizer}.
- SceneGraph now owns the begin/update/commit/cancel history rules as
  methods, with the step helpers as private methods.
- createOriginScene returns a markRaw'd Scene class; operation handles
  are an OperationHandle class instead of inline object literals.
- GestureRecognizer gains static create()/policyFor() factories, absorbing
  the separate policy module.
- Pure helpers used by classes (matchesStart, pointOf, stepSpring,
  composeNodeStyle, nodeEffectLayers, shouldCommit, ...) are exported
  consts so they can be tested directly.
- Add haptics tests showing where navigator.vibrate can be triggered.

Public exports, types, and behavior are unchanged; the differential
suites against main still pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
2026-10-16 02:49:56 +00:00
Claude 329271691c Restructure core-v2 into layered modules
Rewrite the core-v2 internals without changing the public API or runtime
behavior. The four large files (types, scene, motion, gesture) become
layered modules with one responsibility each:

- shared/: geometry and environment helpers
- view/: originView recipes
- choreography/: effect types, presets, and composeEffects compositing
- actions/: forward/back/replace/above/under via one overload resolver
- scene/: SceneGraph storage, transitions (history rules), compositor
  (styles and interactivity), spring (pure stepper + rAF driver), and
  createOriginScene, which only wires them and owns async timing
- composables/: useOrigin and the node-scope injection key
- gesture/: builder, definition->policy normalization, start admission,
  DirectionalTracker, and a GestureRecognizer session state machine
  replacing fifteen loose closure variables
- components/: renderers and gesture hosts, plus their prop types

Verification (packages/core-v2/verify): differential fuzzing drives
main and this branch with identical random operation sequences and
pointer streams and requires identical scene state, styles, contexts,
callbacks, and promise results at every step. It caught every planted
mutation that applied. Benchmarks are at parity.

Also fix a pre-existing test that called the defineProps macro inside a
runtime defineComponent, and correct API.md's description of the flick
commit threshold to match the runtime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
2026-10-16 01:15:12 +00:00
Harvmaster d8303107fb Fix prop typing 2026-08-12 00:28:01 +00:00
Harvmaster 435ac9a899 Add core-v2 skill and make installable 2026-07-31 08:10:01 +00:00
Harvmaster 056f341cd3 Increase complexity of test demo 2026-07-31 07:51:50 +00:00
Harvmaster 55dad11b25 V2: Origin based animations, Gesture Builder, New Demo, non-url-based-routing. Massive improvements. 2026-07-25 05:57:26 +00:00
Harvmaster 5a514906eb Separate out into files 2026-07-22 11:37:46 +00:00
Harvmaster 6aed7606ad Add experiments folder. Add usage.md and SKILL.md. Fix Sheet interactions and add dynamic sheet mode. 2026-07-22 07:04:39 +00:00
Harvmaster bfe364c57d Move components.ts into many SFC files 2026-07-22 02:38:01 +00:00
136 changed files with 22523 additions and 1502 deletions
+2
View File
@@ -27,3 +27,5 @@ apps/demo/dev-dist
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
docker-compose.yml
+51 -1
View File
@@ -4,12 +4,61 @@ Native Vue Router is a gesture-first navigation runtime for Vue 3 and Vue Router
The repository includes a reusable headless core, a platform-adaptive visual preset, Capacitor and Electron adapters, and one messaging demo delivered as a PWA and through native hosts. The repository includes a reusable headless core, a platform-adaptive visual preset, Capacitor and Electron adapters, and one messaging demo delivered as a PWA and through native hosts.
## Install the routeless core-v2 experiment
The repository root is an installable facade for
`@native-vue-router/core-v2`. npm builds the package from source when it installs
the Git dependency:
```bash
npm install git+https://git.harvmaster.com/Harvmaster/Native-Router-Vue.git
```
Import it by its package name and include the compositor stylesheet:
```ts
import {
OriginScene,
createOriginScene,
gesture,
originView,
} from "@native-vue-router/core-v2";
import "@native-vue-router/core-v2/style.css";
```
Pin production applications to a commit or tag:
```bash
npm install "git+https://git.harvmaster.com/Harvmaster/Native-Router-Vue.git#<commit-or-tag>"
```
Core-v2 is routeless and does not depend on Vue Router. Its usage guide and API
are in [packages/core-v2/README.md](packages/core-v2/README.md) and
[packages/core-v2/API.md](packages/core-v2/API.md).
### Codex skill
The Git package also includes
[`build-with-native-vue-router-v2`](skills/build-with-native-vue-router-v2/SKILL.md),
a Codex skill for creating and integrating routeless core-v2 applications.
After installing the dependency, copy the skill into Codex's skill directory:
```bash
mkdir -p "${CODEX_HOME:-$HOME/.codex}/skills"
cp -R \
node_modules/@native-vue-router/core-v2/skills/build-with-native-vue-router-v2 \
"${CODEX_HOME:-$HOME/.codex}/skills/"
```
Restart or reload Codex if necessary, then invoke it explicitly with
`$build-with-native-vue-router-v2`.
## What works ## What works
- 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.
@@ -127,6 +176,7 @@ No rAF loop or browser performance observer runs before `start()`, and `stop()`
Design and engineering documentation: 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) - [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) - [Engineering challenges, Vue Router limitations, and trade-offs](docs/challenges-and-tradeoffs.md)
- [Core principles, scalability, and flexibility](docs/principles-and-scalability.md) - [Core principles, scalability, and flexibility](docs/principles-and-scalability.md)
+277 -7
View File
@@ -1,6 +1,7 @@
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<{ __nvrEvents?: Array<{
@@ -260,7 +261,7 @@ test("renders a suspended pushed sibling and evicts it after backing out", async
"page", "page",
); );
await expect(page.getByTestId("async-data-ready")).toBeVisible({ await expect(page.getByTestId("async-data-ready")).toBeVisible({
timeout: 2_000, timeout: 4_000,
}); });
const lab = page.getByTestId("runtime-lab-view"); const lab = page.getByTestId("runtime-lab-view");
@@ -278,7 +279,7 @@ test("renders a suspended pushed sibling and evicts it after backing out", async
await expect(page).toHaveURL(/\/profile\/runtime-lab$/); await expect(page).toHaveURL(/\/profile\/runtime-lab$/);
await expect(page.getByTestId("async-data-loading")).toBeVisible(); await expect(page.getByTestId("async-data-loading")).toBeVisible();
await expect(page.getByTestId("async-data-ready")).toBeVisible({ await expect(page.getByTestId("async-data-ready")).toBeVisible({
timeout: 2_000, timeout: 4_000,
}); });
await expect(page.getByTestId("runtime-lab-view")).not.toHaveAttribute( await expect(page.getByTestId("runtime-lab-view")).not.toHaveAttribute(
"data-mount-id", "data-mount-id",
@@ -389,6 +390,69 @@ test("manually unloads an inactive route through the public API demo", async ({
await expect(page.getByTestId("stories-view")).toHaveCount(0); 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 ({ test("records a navigation frame profile across route changes", async ({
page, page,
}) => { }) => {
@@ -421,10 +485,213 @@ test("opens and dismisses the compose sheet", async ({ page }) => {
await expect( await expect(
page.getByRole("heading", { name: "New message" }), page.getByRole("heading", { name: "New message" }),
).toBeVisible(); ).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 page.getByRole("button", { name: "Cancel" }).click();
await expect(page.getByRole("heading", { name: "Messages" })).toBeVisible(); 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 }) => { test("keeps the target live during a held component drag", async ({ page }) => {
await page.goto("/inbox"); await page.goto("/inbox");
const row = page.locator(".conversation-row").first(); const row = page.locator(".conversation-row").first();
@@ -528,13 +795,16 @@ test("always opens compose as a vertical sheet after prior navigation", async ({
test("drags a sheet down to dismiss it", async ({ page }) => { test("drags a sheet down to dismiss it", async ({ page }) => {
await page.goto("/inbox"); await page.goto("/inbox");
await page.getByRole("button", { name: "Compose" }).click(); await page.getByRole("button", { name: "Compose" }).click();
const sheet = await page.locator(".sheet-screen").boundingBox(); await expect(page).toHaveURL(/\/compose$/);
if (!sheet) throw new Error("Sheet did not render"); await waitForTransition(page);
await page.mouse.move(sheet.x + sheet.width / 2, sheet.y + 12); 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.down();
await page.mouse.move( await page.mouse.move(
sheet.x + sheet.width / 2, handle.x + handle.width / 2,
sheet.y + sheet.height * 0.55, handle.y + sheet.height * 0.75,
{ steps: 14 }, { steps: 14 },
); );
await page.mouse.up(); await page.mouse.up();
+44
View 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);
}
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
+2
View File
@@ -3,6 +3,7 @@ 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 { demoAppValueKey } from "./compatibility-lab";
import { createPwaAdapter } from "./pwa"; import { createPwaAdapter } from "./pwa";
import { router } from "./router"; import { router } from "./router";
import "./style.css"; import "./style.css";
@@ -27,6 +28,7 @@ const nativeRouter = createNativeRouter({
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");
+25
View File
@@ -104,6 +104,31 @@ const routes: RouteRecordRaw[] = [
native: { presentation: "push", parent: "/profile", gesture: "edge" }, 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({
+259 -13
View File
@@ -28,7 +28,8 @@ html,
body, body,
#app { #app {
width: 100%; width: 100%;
height: 100%; /* Commenting this out for now. Its causing a black bar at the bottom of the screen on iOS */
/* height: 100%; */
margin: 0; margin: 0;
overflow: hidden; overflow: hidden;
overscroll-behavior: none; overscroll-behavior: none;
@@ -65,7 +66,7 @@ html[data-pwa-edge-guard="active"] body {
.app-frame { .app-frame {
position: relative; position: relative;
width: 100%; width: 100%;
height: 100dvh; height: 100vh;
max-width: 740px; max-width: 740px;
margin: 0 auto; margin: 0 auto;
overflow: hidden; overflow: hidden;
@@ -662,18 +663,19 @@ html[data-pwa-edge-guard="active"] body {
font-size: 21px; font-size: 21px;
} }
.sheet-screen { .compose-sheet {
border-radius: 22px 22px 0 0; --nvr-sheet-background: #0b0d12;
--nvr-sheet-backdrop: rgba(0, 0, 0, 0.42);
} }
.sheet-handle { .compose-sheet-content {
position: sticky; min-height: 0;
z-index: 12; background:
top: 8px; radial-gradient(
width: 38px; circle at 88% 4%,
height: 5px; rgba(124, 92, 255, 0.09),
margin: 8px auto 0; transparent 24%
border-radius: 4px; ),
background: #4e5360; #0b0d12;
} }
.sheet-header { .sheet-header {
display: grid; display: grid;
@@ -685,12 +687,23 @@ html[data-pwa-edge-guard="active"] body {
margin: 0; margin: 0;
font-size: 16px; font-size: 16px;
} }
.sheet-header small {
display: block;
margin-top: 2px;
color: var(--muted);
text-align: center;
font-size: 10px;
}
.sheet-header button { .sheet-header button {
justify-self: start; justify-self: start;
border: 0; border: 0;
color: #9b88ff; color: #9b88ff;
background: transparent; background: transparent;
} }
.sheet-header button:last-child {
justify-self: end;
font-size: 11px;
}
.compose-search { .compose-search {
margin: 0 16px 14px; margin: 0 16px 14px;
} }
@@ -884,6 +897,226 @@ html[data-pwa-edge-guard="active"] body {
background: #ff6c76; background: #ff6c76;
box-shadow: 0 0 9px #ff6c76; box-shadow: 0 0 9px #ff6c76;
} }
.compatibility-screen {
padding-bottom: calc(34px + env(safe-area-inset-bottom));
}
.compat-header-state {
padding: 5px 8px;
border: 1px solid rgba(255, 194, 92, 0.28);
border-radius: 999px;
color: #ffc86c;
background: rgba(255, 194, 92, 0.08);
font-size: 9px;
font-weight: 800;
text-transform: uppercase;
}
.compat-header-state.active {
border-color: rgba(61, 217, 170, 0.3);
color: #6ce5bf;
background: rgba(61, 217, 170, 0.08);
}
.compat-intro > span {
width: 64px;
font-size: 15px;
}
.compat-route-state code {
max-width: 52%;
overflow-wrap: anywhere;
color: #b9adff;
font-size: 11px;
text-align: right;
}
.compat-controls {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin: 0 16px 18px;
}
.compat-controls--three {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.compat-controls button,
.compat-inline-actions button,
.compat-probe button,
.compat-event-heading button {
min-height: 42px;
border: 1px solid rgba(124, 92, 255, 0.25);
border-radius: 12px;
color: #c7beff;
background: rgba(124, 92, 255, 0.1);
font-size: 11px;
font-weight: 750;
}
.settings-group > .compat-inline-actions {
min-height: auto;
justify-content: flex-start;
}
.compat-inline-actions button {
flex: 1;
}
.settings-group > .compat-probe-grid {
display: grid;
min-height: 0;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: stretch;
gap: 10px;
padding: 12px;
}
.compat-probe {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
margin: 10px 12px;
padding: 13px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 15px;
background: rgba(255, 255, 255, 0.025);
}
.compat-probe-grid .compat-probe {
margin: 0;
}
.compat-probe__badge {
color: #a998ff;
font-size: 9px;
font-weight: 800;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.compat-probe strong {
font-size: 13px;
}
.compat-probe p {
margin: 0;
color: var(--muted);
font-size: 10px;
line-height: 1.45;
overflow-wrap: anywhere;
}
.compat-probe code {
color: #b9adff;
}
.compat-probe button {
width: 100%;
margin-top: auto;
}
.compat-probe--keepalive {
border-color: rgba(61, 217, 170, 0.16);
background: rgba(61, 217, 170, 0.045);
}
.compat-probe--resolved {
border-color: rgba(61, 217, 170, 0.2);
}
.compat-probe--loading {
min-height: 96px;
flex-direction: row;
align-items: center;
}
.compat-transition-card {
margin: 10px 12px;
padding: 22px 14px;
border: 1px solid rgba(255, 194, 92, 0.22);
border-radius: 15px;
color: #ffd28a;
background: rgba(255, 194, 92, 0.07);
font-size: 12px;
font-weight: 750;
text-align: center;
}
.compat-fade-enter-active,
.compat-fade-leave-active {
transition:
opacity 220ms ease,
transform 220ms ease;
}
.compat-fade-enter-from,
.compat-fade-leave-to {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
.compat-teleport {
position: fixed;
z-index: 1000;
inset: 0;
display: grid;
place-items: center;
padding: 20px;
background: rgba(2, 3, 6, 0.74);
backdrop-filter: blur(12px);
}
.compat-teleport > section {
position: relative;
width: min(440px, 100%);
padding: 20px;
border: 1px solid rgba(169, 152, 255, 0.32);
border-radius: 22px;
background: #141720;
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.58);
}
.compat-teleport > section > button {
position: absolute;
top: 10px;
right: 10px;
width: 34px;
height: 34px;
border: 0;
border-radius: 50%;
color: #fff;
background: rgba(255, 255, 255, 0.08);
font-size: 20px;
}
.compat-teleport h2 {
margin: 0 44px 15px 0;
font-size: 18px;
}
.compat-teleport .compat-probe {
margin: 0;
}
.settings-group > .compat-event-heading {
min-height: 66px;
}
.compat-event-heading button {
min-height: 34px;
padding: 0 12px;
}
.compat-event-log {
max-height: 360px;
margin: 0;
padding: 0;
overflow-y: auto;
border-top: 1px solid var(--line);
list-style: none;
}
.compat-event-log li {
display: grid;
grid-template-columns: 58px minmax(0, 1fr) auto;
gap: 10px;
align-items: start;
padding: 9px 13px;
border-bottom: 1px solid rgba(255, 255, 255, 0.045);
}
.compat-event-log time,
.compat-event-log small {
color: #686e7b;
font-size: 9px;
}
.compat-event-log span {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.compat-event-log strong {
overflow: hidden;
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.compat-event-log code {
color: #afa1ff;
font-size: 10px;
}
.empty-state { .empty-state {
display: grid; display: grid;
place-items: center; place-items: center;
@@ -926,6 +1159,19 @@ html[data-pwa-edge-guard="active"] body {
} }
} }
@media (max-width: 520px) {
.compat-controls--three,
.settings-group > .compat-probe-grid {
grid-template-columns: 1fr;
}
.compat-event-log li {
grid-template-columns: 52px minmax(0, 1fr);
}
.compat-event-log li > small {
display: none;
}
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *,
*::before, *::before,
+44 -15
View File
@@ -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>
+33 -11
View File
@@ -1,12 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { NativeLink, useNativeRouter } 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' import { setStoryEntryBlocked, storyEntryGuard } from "../guard-state";
const native = useNativeRouter() const native = useNativeRouter();
function toggleStoryGuard() { function toggleStoryGuard() {
setStoryEntryBlocked(!storyEntryGuard.blockEntry) setStoryEntryBlocked(!storyEntryGuard.blockEntry);
} }
</script> </script>
@@ -24,13 +24,35 @@ function toggleStoryGuard() {
</div> </div>
</section> </section>
<section class="settings-list"> <section class="settings-list">
<a href="/profile/runtime-lab" @click.prevent="native.sibling('/profile/runtime-lab')"><span>⌁</span><strong>Runtime stress lab</strong><i>›</i></a> <a
<button type="button" aria-label="Block Stories re-entry" :aria-pressed="storyEntryGuard.blockEntry" @click="toggleStoryGuard"> href="/profile/runtime-lab"
<span>⌽</span><strong>Block cached Stories re-entry</strong><i data-testid="story-guard-status">{{ storyEntryGuard.blockEntry ? storyEntryGuard.status : 'Off' }}</i> @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> </button>
<NativeLink to="/settings"><span>⚙︎</span><strong>Navigation 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>
@@ -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>
@@ -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>
+1
View File
@@ -0,0 +1 @@
dev-dist
+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<meta name="theme-color" content="#080b12" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-status-bar-style"
content="black-translucent"
/>
<meta name="apple-mobile-web-app-title" content="Origins" />
<meta name="format-detection" content="telephone=no" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" href="/favicon.svg" />
<title>Routeless Origins Lab</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@native-vue-router/origins-demo",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --config vite.config.ts --host",
"build": "vue-tsc -p tsconfig.json --noEmit && vite build --config vite.config.ts",
"preview": "vite preview --config vite.config.ts --host 0.0.0.0 --port 5173"
},
"dependencies": {
"@native-vue-router/core-v2": "0.1.0-experimental.0",
"vue": "^3.5.39"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.7",
"typescript": "~6.0.2",
"vite": "^8.1.1",
"vite-plugin-pwa": "^1.1.0",
"vue-tsc": "^3.3.5"
}
}
+78
View File
@@ -0,0 +1,78 @@
<script setup lang="ts">
import {
OriginScene,
createOriginScene,
originView,
} from "@native-vue-router/core-v2";
import { labEvents } from "./lab-state";
import HubView from "./views/HubView.vue";
/*
* This scene is the entire application state for the experiment. There is no
* Vue Router installation, route table, URL, or hidden RouterView.
*/
const scene = createOriginScene({
initial: originView(HubView, undefined, {
key: "origins-lab",
name: "Origins Lab",
}),
});
</script>
<template>
<div class="demo-shell">
<OriginScene :scene="scene" />
<details class="scene-debug">
<summary>
<span
class="debug-pulse"
:class="{ live: scene.operations.value.length }"
/>
{{ scene.nodes.value.length }} node{{
scene.nodes.value.length === 1 ? "" : "s"
}}
</summary>
<div class="debug-content" aria-live="polite">
<section>
<strong>Scene graph</strong>
<ol>
<li v-for="node in scene.nodes.value" :key="node.key">
<span>
{{ node.view.name }}
<em :class="`node-state node-state--${node.state}`">
{{ node.state }}
</em>
</span>
<code>{{ node.key.split("::").at(-1) }}</code>
</li>
</ol>
</section>
<section v-if="scene.operations.value.length">
<strong>Animation edges</strong>
<div
v-for="operation in scene.operations.value"
:key="operation.id"
class="debug-operation"
>
<span>{{ operation.choreography.name }}</span>
<progress :value="operation.progress" max="1" />
<small>
{{ operation.phase }} ·
{{ Math.round(operation.progress * 100) }}%
</small>
</div>
</section>
<section>
<strong>Recent Vue lifecycle</strong>
<ul class="event-log">
<li v-for="event in labEvents.slice(0, 6)" :key="event.id">
<time>{{ event.time }}</time>
{{ event.message }}
</li>
</ul>
</section>
</div>
</details>
</div>
</template>
@@ -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
View File
@@ -0,0 +1,37 @@
export interface DemoPhoto {
id: string;
title: string;
location: string;
gradient: string;
description: string;
}
export const demoPhotos: readonly DemoPhoto[] = [
{
id: "ember",
title: "Afterglow",
location: "Reykjanes · 23:14",
gradient:
"radial-gradient(circle at 72% 25%, #ffcc8a 0, #ff774d 18%, transparent 43%), linear-gradient(145deg, #622c54, #171526 72%)",
description:
"A warm focus portal demonstrates scale, blur, opacity, and rounded-corner effects.",
},
{
id: "tide",
title: "Low tide",
location: "Lofoten · 05:42",
gradient:
"radial-gradient(circle at 25% 25%, #b8f2ff 0, #3f9eb8 24%, transparent 48%), linear-gradient(160deg, #123b50, #07131f 72%)",
description:
"Swipe left while this view is present to create the next photo as another origin.",
},
{
id: "moss",
title: "Quiet giant",
location: "Yakushima · 16:08",
gradient:
"radial-gradient(circle at 64% 30%, #d9ff9f 0, #608b52 22%, transparent 45%), linear-gradient(145deg, #294235, #091812 74%)",
description:
"The like button is component-local state. Move forward, then return to see the same retained instance and state.",
},
];
+49
View File
@@ -0,0 +1,49 @@
import { onBeforeUnmount, onMounted, reactive, ref } from "vue";
export interface LabEvent {
id: number;
message: string;
time: string;
}
let instanceSequence = 0;
let eventSequence = 0;
export const labEvents = reactive<LabEvent[]>([]);
export function recordLabEvent(message: string) {
labEvents.unshift({
id: ++eventSequence,
message,
time: new Date().toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}),
});
if (labEvents.length > 18) labEvents.length = 18;
}
/**
* Give every Vue setup execution a visible identity. A scene-edge collapse
* and a forward push both retain the ID. A committed back only unmounts the
* entry being popped, then reveals the exact previous instance.
*/
export function useDemoInstance(label: string) {
const instance = `${label.slice(0, 2).toUpperCase()}-${String(
++instanceSequence,
).padStart(2, "0")}`;
const status = ref("created");
recordLabEvent(`${instance} created`);
onMounted(() => {
status.value = "mounted";
recordLabEvent(`${instance} mounted`);
});
onBeforeUnmount(() => {
status.value = "unmounting";
recordLabEvent(`${instance} unmounted`);
});
return { instance, status };
}
+13
View File
@@ -0,0 +1,13 @@
import { createApp } from "vue";
import { registerSW } from "virtual:pwa-register";
import App from "./App.vue";
import "./style.css";
/*
* Registration is deliberately immediate so an installed app becomes
* offline-capable during its first session. `autoUpdate` in the Vite plugin
* activates a fresh build without requiring a custom update prompt yet.
*/
registerSW({ immediate: true });
createApp(App).mount("#app");
+376
View 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" },
},
}),
});
File diff suppressed because it is too large Load Diff
+65
View File
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { nextTick, ref } from "vue";
import { back, slideRight, useOrigin } from "@native-vue-router/core-v2";
import InstanceCard from "../components/InstanceCard.vue";
import { useDemoInstance } from "../lab-state";
const origin = useOrigin();
const identity = useDemoInstance("Chat");
const draft = ref("");
const messages = ref([
"This page deliberately owns no edge recognizer.",
"Try swiping right. Nothing should happen.",
]);
function goBack() {
if (origin.context.value.canGoBack) void origin.perform(back(slideRight));
}
async function send() {
const message = draft.value.trim();
if (!message) return;
messages.value.push(message);
draft.value = "";
await nextTick();
}
</script>
<template>
<!--
No gesture binding is used here. Gesture absence is the policy, rather
than a global back gesture followed by an exception.
-->
<div class="chat-view">
<header class="chat-header">
<button type="button" @click="goBack">←</button>
<div class="chat-avatar">M</div>
<div>
<strong>Maya</strong>
<span>online</span>
</div>
<span class="chat-lock">edge locked</span>
</header>
<main class="messages">
<div
v-for="(message, index) in messages"
:key="`${index}-${message}`"
class="message"
:class="{ 'message--mine': index > 1 }"
>
{{ message }}
</div>
<InstanceCard
label="Chat instance"
:instance="identity.instance"
:status="identity.status.value"
/>
</main>
<form class="composer" @submit.prevent="send">
<input v-model="draft" placeholder="Write something…" />
<button type="submit">Send</button>
</form>
</div>
</template>
@@ -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>
@@ -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>
@@ -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>
@@ -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
View 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>
@@ -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>
@@ -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
View 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>
@@ -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>
@@ -0,0 +1,276 @@
<script setup lang="ts">
import {
OriginGesture,
OriginScene,
back,
createOriginScene,
gesture,
originView,
useOrigin,
type OriginView,
} from "@native-vue-router/core-v2";
import { useDemoInstance } from "../lab-state";
import { unfocusPortal } from "../motions";
import NestedSceneSlide from "./NestedSceneSlide.vue";
type NestedSceneKind = "cooperative" | "vertical" | "conflict";
interface SlideCopy {
eyebrow: string;
title: string;
description: string;
accent: string;
}
const slideSets: Record<NestedSceneKind, readonly SlideCopy[]> = {
cooperative: [
{
eyebrow: "Cooperative carousel",
title: "A scene inside a page",
description:
"Swipe left away from the glowing pass-through rail. The target mounts only inside this clipped carousel.",
accent: "#76dcff",
},
{
eyebrow: "Retained nested history",
title: "Slide one is still mounted",
description:
"Increment local state, move again, then return. The same nested Vue instances are revealed.",
accent: "#a78bfa",
},
{
eyebrow: "Independent operation graph",
title: "Three carousel instances",
description:
"This history belongs to the carousel scene. The outer page remains one mounted application entry.",
accent: "#65f2b1",
},
],
vertical: [
{
eyebrow: "Vertical nested deck",
title: "Pull upward inside this card",
description:
"The deck uses vertical progress, rotation, and a shared sideways frame arc.",
accent: "#ffbd70",
},
{
eyebrow: "Orthogonal choreography",
title: "The page still owns its left rail",
description:
"Pull down to reveal the retained card, or begin on its pass-through rail and swipe right to leave the lab.",
accent: "#ff7f9f",
},
{
eyebrow: "Nested composition",
title: "Frames remain local",
description:
"Transforms compose exactly like the full-screen scene, but measurements come from this smaller viewport.",
accent: "#ffe079",
},
],
conflict: [
{
eyebrow: "Intentional conflict",
title: "This child claims every pointer-down",
description:
"Try the page's rightward back gesture over this card. The child sees it first, even though nested back is unavailable.",
accent: "#ff5f78",
},
{
eyebrow: "Child-owned back",
title: "Now rightward swipe works here",
description:
"After moving forward, the exact same gesture can resolve nested back, producing the cube reversal.",
accent: "#ff8d4d",
},
],
};
function nestedView(kind: NestedSceneKind, index: number): OriginView {
const slides = slideSets[kind];
const copy = slides[index]!;
return originView(
NestedSceneSlide,
{
kind,
index,
total: slides.length,
...copy,
next:
index + 1 < slides.length
? (): OriginView => nestedView(kind, index + 1)
: undefined,
},
{
key: `${kind}-slide-${index + 1}`,
name: `${kind} slide ${index + 1}`,
},
);
}
function nestedScene(kind: NestedSceneKind) {
return createOriginScene({ initial: nestedView(kind, 0) });
}
interface NestedCase {
kind: NestedSceneKind;
status: "supported" | "supported-with-policy" | "known-conflict";
title: string;
summary: string;
expected: string;
issue: string;
scene: ReturnType<typeof createOriginScene>;
}
const cases: readonly NestedCase[] = [
{
kind: "cooperative",
status: "supported-with-policy",
title: "Reserved-edge carousel",
summary:
"Horizontal nested history with a start predicate that leaves a clamp(22px, 12%, 52px) rail for its parent.",
expected:
"Swipe left or right beyond the rail; swipe right from the card's rail to trigger parent back.",
issue:
"The parent/child agreement is spatial policy written by the developer, not automatic arbitration.",
scene: nestedScene("cooperative"),
},
{
kind: "vertical",
status: "supported",
title: "Vertical component deck",
summary:
"A second nested scene uses upward/downward gestures and an entirely different frame animation.",
expected:
"Swipe up for the next card, down for retained back, and swipe right from its rail for parent back.",
issue:
"Its touch-action prevents page scrolling while a touch begins over the deck, which is appropriate here but must be intentional.",
scene: nestedScene("vertical"),
},
{
kind: "conflict",
status: "known-conflict",
title: "Greedy child recognizer",
summary:
"The cube carousel deliberately uses `.to.left()` and `.to.right()` without a `.from` constraint.",
expected: "Nested forward/back works and remains clipped to the card.",
issue:
"On slide one, parent back fails everywhere over this card: the child stops pointer-down propagation before `.navigate()` returns null.",
scene: nestedScene("conflict"),
},
];
const origin = useOrigin();
const identity = useDemoInstance("Nested scenes");
const pageBackGesture = gesture.to
.right()
.complete(
({ distance, bounds, progress, velocity }) =>
distance >= bounds.width * 0.08 || progress >= 0.22 || velocity >= 0.65,
)
.navigate((context) => (context.canGoBack ? back() : null))
.animate(unfocusPortal);
function backWithButton() {
if (origin.context.value.canGoBack) void origin.perform(back(unfocusPortal));
}
</script>
<template>
<OriginGesture class="lab-page nested-scenes-view" :gesture="pageBackGesture">
<button
class="view-back"
type="button"
data-origin-gesture="ignore"
@click="backWithButton"
>
← Labs
</button>
<header class="nested-scenes-header">
<div>
<div class="eyebrow">Lab 07 · Nested origin scenes</div>
<h1>Components can own smaller worlds.</h1>
<p>
Each card below contains a completely independent
<code>OriginScene</code>. Its views, history, gestures, measurements,
and animations stay inside that card while this page remains the outer
origin.
</p>
</div>
<div class="nested-page-instance">
<span>Outer page instance</span>
<strong>{{ identity.instance }}</strong>
<small>rightward 8% → page back</small>
</div>
</header>
<main class="nested-case-grid">
<section
v-for="item in cases"
:key="item.kind"
class="nested-case"
:class="`nested-case--${item.kind}`"
>
<header>
<span class="nested-case__status" :class="item.status">
{{ item.status.replaceAll("-", " ") }}
</span>
<h2>{{ item.title }}</h2>
<p>{{ item.summary }}</p>
</header>
<div class="nested-stage-shell">
<OriginScene class="nested-stage" :scene="item.scene" />
<div class="nested-stage-edge" aria-hidden="true">
{{
item.kind === "conflict"
? "parent blocked"
: "parent pass-through"
}}
</div>
</div>
<div class="nested-case__diagnostics">
<span>
{{ item.scene.nodes.value.length }} nested node{{
item.scene.nodes.value.length === 1 ? "" : "s"
}}
</span>
<span>
{{ item.scene.operations.value.length }} live edge{{
item.scene.operations.value.length === 1 ? "" : "s"
}}
</span>
</div>
<dl>
<div>
<dt>Expected</dt>
<dd>{{ item.expected }}</dd>
</div>
<div>
<dt>
{{ item.kind === "conflict" ? "Predicted failure" : "Caveat" }}
</dt>
<dd>{{ item.issue }}</dd>
</div>
</dl>
</section>
</main>
<footer class="nested-scenes-footer">
<strong>Ownership today is decided at pointer-down.</strong>
<span>
A future gesture arena could delay that decision and let a parent claim
the sequence when a child declines navigation.
</span>
</footer>
<div class="nested-page-gesture">
Parent gesture: swipe right outside a child, or from a cooperative rail
</div>
</OriginGesture>
</template>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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
View File
@@ -0,0 +1,105 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from "vue";
import {
OriginGesture,
back,
gesture,
useOrigin,
type OriginContext,
} from "@native-vue-router/core-v2";
import InstanceCard from "../components/InstanceCard.vue";
import { useDemoInstance } from "../lab-state";
import { coverDown } from "../motions";
const origin = useOrigin();
const identity = useDemoInstance("Player");
const playing = ref(false);
const position = ref(38);
const elapsed = computed(() => `1:${String(position.value).padStart(2, "0")}`);
const timer = window.setInterval(() => {
if (playing.value) position.value = (position.value + 1) % 60;
}, 1000);
onBeforeUnmount(() => window.clearInterval(timer));
const dismiss = (context: OriginContext) =>
context.canGoBack ? back(coverDown) : null;
const dismissGesture = gesture.to
.down()
.navigate((context) => (context.canGoBack ? back() : null))
.animate(coverDown);
function close() {
const action = dismiss(origin.context.value);
if (action) void origin.perform(action);
}
</script>
<template>
<OriginGesture class="player-view" :gesture="dismissGesture">
<header class="player-header">
<button type="button" data-origin-gesture="ignore" @click="close">
⌄
</button>
<span>Now playing</span>
<button type="button" data-origin-gesture="ignore">•••</button>
</header>
<div class="album-art">
<span class="album-ring album-ring--one" />
<span class="album-ring album-ring--two" />
<span class="album-center">O</span>
</div>
<section class="track-copy">
<div>
<h1>Independent Frames</h1>
<p>The Scene Graph</p>
</div>
<button
type="button"
data-origin-gesture="ignore"
:aria-pressed="playing"
@click="playing = !playing"
>
{{ playing ? "♥" : "♡" }}
</button>
</section>
<label class="timeline">
<input
v-model="position"
data-origin-gesture="ignore"
type="range"
min="0"
max="59"
/>
<span>{{ elapsed }}</span>
<span>3:42</span>
</label>
<div class="player-controls">
<button type="button" data-origin-gesture="ignore">↶</button>
<button
class="play-button"
type="button"
data-origin-gesture="ignore"
@click="playing = !playing"
>
{{ playing ? "Ⅱ" : "▶" }}
</button>
<button type="button" data-origin-gesture="ignore">↷</button>
</div>
<InstanceCard
label="Player instance"
:instance="identity.instance"
:status="identity.status.value"
/>
<p class="policy-note">
Swipe down anywhere to dismiss. Horizontal back is intentionally absent,
like a native full-screen player.
</p>
<div class="drag-handle" />
</OriginGesture>
</template>
@@ -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
View File
@@ -0,0 +1,60 @@
<script setup lang="ts">
import {
above,
back,
gesture,
OriginGestureSurface,
originView,
slideRight,
} from "@native-vue-router/core-v2";
import InstanceCard from "../components/InstanceCard.vue";
import { useDemoInstance } from "../lab-state";
import { arcSlide } from "../motions";
import FourthView from "./FourthView.vue";
defineProps<{ createdBy?: string }>();
const identity = useDemoInstance("Z");
const fourthView = () =>
originView(
FourthView,
{ chain: `X → Y → ${identity.instance}` },
{ key: "fourth", name: "Omega" },
);
const forwardGesture = gesture.to
.left()
.navigate(() => above(fourthView()))
.animate(arcSlide);
const backGesture = gesture
// .from.left("clamp(24px, 8%, 64px)")
// .from.left("20%")
.to.right()
.navigate((context) => (context.canGoBack ? back() : null))
.animate(slideRight);
const gestures = [forwardGesture, backGesture] as const;
</script>
<template>
<OriginGestureSurface
as="main"
class="lab-page chain-view view-three"
:gestures="gestures"
>
<div class="eyebrow">Chain stress test · Origin Z</div>
<h1>Now add an arc while two frames may still be moving.</h1>
<p>
Z inherits 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
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.app.json",
"compilerOptions": {
"tsBuildInfoFile": "../../node_modules/.tmp/origins-demo.tsbuildinfo",
"baseUrl": ".",
"paths": {
"@native-vue-router/core-v2": ["../../packages/core-v2/src/index.ts"]
}
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}
+174
View File
@@ -0,0 +1,174 @@
import path from "node:path";
import vue from "@vitejs/plugin-vue";
import { defineConfig, type Plugin } from "vite";
import { VitePWA } from "vite-plugin-pwa";
/**
* Recover browsers that visited this hostname while Docker still served Vite's
* development mode. Its development worker can retain HTML that imports
* `/@vite/client` and `/src/main.ts`; a normal static fallback would return
* HTML for those module URLs and leave the visitor on a blank screen.
*
* This preview-only compatibility endpoint is requested only by that obsolete
* shell. It clears the old origin-local worker/cache state and writes the
* current production HTML into the document. The production application then
* registers the real `/sw.js` worker normally.
*/
function legacyDevelopmentWorkerMigration(): Plugin {
const migrationSource = `
void (async () => {
if ("serviceWorker" in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
await Promise.all(registrations.map((registration) => registration.unregister()));
}
if ("caches" in window) {
const names = await caches.keys();
await Promise.all(names.map((name) => caches.delete(name)));
}
const productionDocument = await fetch(
"/index.html?origin-pwa-migration=" + Date.now(),
{ cache: "no-store" },
);
const html = await productionDocument.text();
document.open();
document.write(html);
document.close();
})();
`;
return {
name: "origins-legacy-development-worker-migration",
configurePreviewServer(server) {
server.middlewares.use((request, response, next) => {
const pathname = new URL(request.url ?? "/", "http://origins.local")
.pathname;
if (pathname === "/@vite/client") {
response.statusCode = 200;
response.setHeader("Content-Type", "text/javascript; charset=utf-8");
response.setHeader("Cache-Control", "no-store");
response.end(
"// Compatibility stub for the retired Vite dev client.",
);
return;
}
if (pathname === "/src/main.ts") {
response.statusCode = 200;
response.setHeader("Content-Type", "text/javascript; charset=utf-8");
response.setHeader("Cache-Control", "no-store");
response.end(migrationSource);
return;
}
next();
});
},
};
}
export default defineConfig({
root: __dirname,
base: "./",
// Reuse the workspace's existing install icons rather than maintaining a
// second set that can silently drift from the main demo.
publicDir: path.resolve(__dirname, "../../public"),
plugins: [
vue(),
legacyDevelopmentWorkerMigration(),
VitePWA({
registerType: "autoUpdate",
devOptions: {
// The hosted Docker service runs Vite's development server, so its
// public HTTPS URL also needs a real manifest and service worker.
enabled: true,
navigateFallback: "index.html",
suppressWarnings: true,
},
includeAssets: [
"favicon.svg",
"app-icon.svg",
"apple-touch-icon.png",
"pwa-192.png",
"pwa-512.png",
],
manifest: {
id: "/",
name: "Routeless Origins Lab",
short_name: "Origins Lab",
description:
"Seven interactive labs for a routeless Vue animation and gesture engine.",
theme_color: "#080b12",
background_color: "#080b12",
display: "standalone",
scope: "/",
start_url: "/",
orientation: "any",
icons: [
{
src: "/pwa-192.png",
sizes: "192x192",
type: "image/png",
purpose: "any",
},
{
src: "/pwa-512.png",
sizes: "512x512",
type: "image/png",
purpose: "any",
},
{
src: "/pwa-512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
{
src: "/app-icon.svg",
sizes: "any",
type: "image/svg+xml",
purpose: "any",
},
],
},
workbox: {
cleanupOutdatedCaches: true,
clientsClaim: true,
skipWaiting: true,
navigateFallback: "/index.html",
globPatterns: ["**/*.{js,css,html,svg,png,woff2}"],
},
}),
],
server: {
// The development container is reached through this Traefik hostname.
// Keeping the allow-list explicit preserves Vite's DNS-rebinding defense.
allowedHosts: ["v2.demo.native-router.harvmaster.com"],
},
preview: {
allowedHosts: ["v2.demo.native-router.harvmaster.com"],
},
resolve: {
alias: [
{
find: /^@native-vue-router\/core-v2\/style\.css$/,
replacement: path.resolve(
__dirname,
"../../packages/core-v2/src/style.css",
),
},
{
find: /^@native-vue-router\/core-v2$/,
replacement: path.resolve(
__dirname,
"../../packages/core-v2/src/index.ts",
),
},
],
},
build: {
outDir: path.resolve(__dirname, "dist"),
emptyOutDir: true,
},
});
+23 -1
View File
@@ -20,6 +20,28 @@ Pointer movement changes only a CSS progress variable. Built-in presentations re
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. 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`.
## Route metadata ## Route metadata
@@ -87,7 +109,7 @@ useNativeViewActiveEffect(() => {
onNativeViewEvict((reason) => saveDraft(view.route.value, reason)); onNativeViewEvict((reason) => saveDraft(view.route.value, reason));
``` ```
`isActive` means the route is authoritative. `isVisible` also includes either side of an in-progress transition. Use `useNativeViewActiveEffect` for polling and other work that should pause in a cached tab, or `useNativeViewVisibleEffect` for work needed during the animation. Application data that must survive eviction belongs in an application store. `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 ## Optional performance profiler
+4 -2
View File
@@ -70,9 +70,9 @@ Transactions are interruptible. Starting a new navigation while a spring is sett
## Rendering and presentation ## Rendering and presentation
`NativeRouterView` keeps cached route entries as sibling layers. At rest, only the active layer is visible and interactive. During a transaction, exactly the `from` and `to` entries receive active roles. `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. 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. 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. 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.
@@ -88,6 +88,8 @@ Ownership is explicit:
- A component gesture link owns drags that begin on that component away from the back edge. - A component gesture link owns drags that begin on that component away from the back edge.
- A sheet dismissal surface owns downward vertical drags. - 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. 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 ## History and cache are intentionally separate
+1 -1
View File
@@ -26,4 +26,4 @@ The checked-in iOS and Android projects use Capacitor 8 and include App, Haptics
## 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.
+140
View 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>`.
@@ -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.
@@ -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>
@@ -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"
}
}
@@ -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>
@@ -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";
}
@@ -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);
});
@@ -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),
});
});
@@ -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>
@@ -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"]
}
@@ -0,0 +1,6 @@
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [vue()],
});
+393 -42
View File
File diff suppressed because it is too large Load Diff
+47 -16
View File
@@ -1,8 +1,36 @@
{ {
"name": "native-vue-router-workspace", "name": "@native-vue-router/core-v2",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0-experimental.0",
"description": "Routeless, component-owned scene transitions and gesture recognition for Vue 3.",
"type": "module", "type": "module",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://git.harvmaster.com/Harvmaster/Native-Router-Vue.git"
},
"files": [
"packages/core-v2/dist",
"packages/core-v2/API.md",
"packages/core-v2/README.md",
"skills/build-with-native-vue-router-v2"
],
"main": "./packages/core-v2/dist/index.js",
"types": "./packages/core-v2/dist/index.d.ts",
"exports": {
".": {
"types": "./packages/core-v2/dist/index.d.ts",
"import": "./packages/core-v2/dist/index.js"
},
"./style.css": "./packages/core-v2/dist/style.css",
"./package.json": "./package.json"
},
"sideEffects": [
"./packages/core-v2/dist/style.css"
],
"peerDependencies": {
"vue": "^3.5.0"
},
"workspaces": [ "workspaces": [
"packages/*", "packages/*",
"apps/*" "apps/*"
@@ -10,19 +38,27 @@
"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",
"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}\"" "format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,css,md}\"",
"prepare": "npm run build --workspace packages/core-v2",
"test:feel": "NODE_ENV=development vite build --mode development --config packages/core-v2/verify/browser/app/vite.config.mjs && node packages/core-v2/verify/browser/run.mjs"
}, },
"dependencies": { "devDependencies": {
"@capacitor/app": "^8.0.0", "@capacitor/app": "^8.0.0",
"@capacitor/android": "^8.0.0",
"@capacitor/cli": "^8.0.0",
"@capacitor/core": "^8.0.0", "@capacitor/core": "^8.0.0",
"@capacitor/haptics": "^8.0.0", "@capacitor/haptics": "^8.0.0",
"@capacitor/ios": "^8.0.0",
"@capacitor/splash-screen": "^8.0.0", "@capacitor/splash-screen": "^8.0.0",
"@capacitor/status-bar": "^8.0.0", "@capacitor/status-bar": "^8.0.0",
"@lucide/vue": "^1.24.0", "@lucide/vue": "^1.24.0",
@@ -30,27 +66,22 @@
"@native-vue-router/core": "0.1.0", "@native-vue-router/core": "0.1.0",
"@native-vue-router/electron": "0.1.0", "@native-vue-router/electron": "0.1.0",
"@native-vue-router/preset-native": "0.1.0", "@native-vue-router/preset-native": "0.1.0",
"@tailwindcss/vite": "^4.3.2",
"tailwindcss": "^4.3.2",
"tw-animate-css": "^1.4.0",
"vite-plugin-pwa": "^1.1.0",
"vue": "^3.5.39",
"vue-router": "^5.0.6"
},
"devDependencies": {
"@capacitor/android": "^8.0.0",
"@capacitor/cli": "^8.0.0",
"@capacitor/ios": "^8.0.0",
"@playwright/test": "^1.55.0", "@playwright/test": "^1.55.0",
"@tailwindcss/vite": "^4.3.2",
"@types/node": "^24.13.3", "@types/node": "^24.13.3",
"@vitejs/plugin-vue": "^6.0.7", "@vitejs/plugin-vue": "^6.0.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", "prettier": "^3.9.6",
"tailwindcss": "^4.3.2",
"tw-animate-css": "^1.4.0",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"vite": "^8.1.1", "vite": "^8.1.1",
"vite-plugin-pwa": "^1.1.0",
"vitest": "^3.2.4", "vitest": "^3.2.4",
"vue": "^3.5.39",
"vue-router": "^5.0.6",
"vue-tsc": "^3.3.5" "vue-tsc": "^3.3.5"
} }
} }
File diff suppressed because it is too large Load Diff
+342
View File
@@ -0,0 +1,342 @@
# 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).
## Install from Git
The repository root exposes this workspace package, runs its build during npm's
Git-dependency preparation, and includes the generated JavaScript, declarations,
and stylesheet:
```bash
npm install git+https://git.harvmaster.com/Harvmaster/Native-Router-Vue.git
```
Pin applications to a commit or tag when reproducibility matters:
```bash
npm install "git+https://git.harvmaster.com/Harvmaster/Native-Router-Vue.git#<commit-or-tag>"
```
Vue 3.5 or newer is the only peer dependency.
The Git-installed repository package includes a Codex integration skill at
`skills/build-with-native-vue-router-v2`. Copy that folder into
`${CODEX_HOME:-$HOME/.codex}/skills` and invoke it as
`$build-with-native-vue-router-v2`.
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.
## Source layout
Each module depends only on the ones listed above it.
| File | Responsibility |
| ----------------------- | ------------------------------------------------------------------------------------------------- |
| `shared.ts` | Geometry and environment helpers |
| `choreography.ts` | Effect types, `defineOriginChoreography()`, presets, and `composeEffects()` style compositing |
| `navigation.ts` | `originView()` recipes and `forward()`, `back()`, `replace()`, `above()`, `under()` actions |
| `scene/types.ts` | Public scene types |
| `scene/graph.ts` | `SceneGraph`: node and edge storage plus the begin/commit/cancel history rules (all synchronous) |
| `scene/scene.ts` | `Scene` and `OperationHandle`: mounting, measuring, settling spring, compositing, and `useOrigin` |
| `gesture/definition.ts` | Gesture types, component props, and the immutable `gesture` builder |
| `gesture/recognizer.ts` | Pointer-down admission, `DirectionalTracker`, `GestureRecognizer`, and `useOriginGesture` |
| `components/` | `OriginScene`, `OriginGesture`, `OriginGestureSurface`, and the internal node host |
Tests live in `tests/`, outside `src/`. `tests/property.test.ts` checks the
scene's structural invariants after every step of random operation sequences
and pointer streams; set `PROPERTY_SEEDS` to run more than the default 60.
Pure helpers used by a class (for example `matchesStart`, `stepSpring`, or
`composeNodeStyle`) are module-level `export const`s that the class calls, so
they can be tested directly without widening the class's interface.
## 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.
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@native-vue-router/core-v2",
"version": "0.1.0-experimental.0",
"type": "module",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://git.harvmaster.com/Harvmaster/Native-Router-Vue.git",
"directory": "packages/core-v2"
},
"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"
}
}
+319
View File
@@ -0,0 +1,319 @@
import type { CSSProperties } from "vue";
import { clamp01, type OriginRect } from "./shared";
/*
* Choreographies: the visual side of an operation. A choreography computes
* effects from operation progress; `composeEffects` collapses the effects
* applied to one node into its inline style. Nothing here knows about history.
*/
/**
* The lifecycle phase of an operation edge.
*
* - `preparing`: the target has been added and Vue is mounting it.
* - `interactive`: progress may be controlled by a gesture or application.
* - `settling`: the commit/cancel decision is fixed and the spring is running.
* - `finished`: the graph rewrite or cancellation cleanup has completed.
*/
export type OriginOperationPhase =
"preparing" | "interactive" | "settling" | "finished";
/**
* The outcome selected for an operation.
*
* Intent remains `undecided` until `finish()` is called. It becomes final
* before the settling animation completes, allowing the retained target to
* originate its own operation immediately.
*/
export type OriginOperationIntent = "undecided" | "commit" | "cancel";
/**
* One composable contribution to a scene node's final visual style.
*
* Transforms are concatenated in origin-to-descendant order and opacity values
* are multiplied. This lets X→Y and Y→Z affect Y simultaneously without either
* routine replacing the other routine's CSS transform.
*/
export interface OriginEffect {
/**
* A CSS transform contribution. Transform strings from inherited and local
* origin frames are concatenated rather than replacing one another.
*/
transform?: string;
/**
* An opacity contribution between `0` and `1`. Contributions from multiple
* frames are multiplied and the final value is clamped.
*/
opacity?: number;
/**
* Relative stacking contribution. `above()` defaults the target to +1 and
* `under()` defaults it to -1.
*/
layer?: number;
/**
* Escape hatch for non-geometric effects such as filter, clipPath, or
* borderRadius. Later/local effects override inherited properties.
*/
style?: CSSProperties;
}
/**
* Independent visual contributions calculated for one operation edge.
*
* Any omitted contribution is treated as an identity effect.
*/
export interface OriginEffectSet {
/**
* Applied to both source and target. This is useful for moving an entire
* origin-relative coordinate frame.
*/
frame?: OriginEffect;
/** Applied to the component that initiated this operation. */
source?: OriginEffect;
/** Applied to the component created by this operation and its descendants. */
target?: OriginEffect;
}
/** Values supplied whenever a choreography calculates its visual effects. */
export interface OriginChoreographyContext {
/** Normalized operation progress, clamped to the inclusive range `0..1`. */
progress: number;
/**
* How far a drag has gone past the ends of `progress`: negative when pulled
* back beyond the start, positive beyond full progress, otherwise `0`. Use
* it for rubber-banding or resistance; most choreographies ignore it.
*/
overshoot: number;
/**
* Normalized progress units per second. Positive velocity moves toward
* commit; negative velocity moves back toward cancellation.
*/
velocity: number;
/** Current lifecycle phase of the operation. */
phase: OriginOperationPhase;
/** Commit/cancel outcome, if release has already selected one. */
intent: OriginOperationIntent;
/** Origin host bounds captured immediately before the target mounts. */
originRect?: OriginRect;
/** Target host bounds measured after Vue mounts the target. */
targetRect?: OriginRect;
/** Scene-container bounds, or the browser viewport when no container exists. */
viewport: OriginRect;
}
/**
* A choreography describes visual relationships only. Component creation,
* history, and cleanup are performed by the scene operation that uses it.
*/
export interface OriginChoreography {
/** Optional diagnostic name surfaced by scene inspectors and devtools. */
readonly name?: string;
/**
* Calculate source, target, and shared-frame contributions for the current
* operation state. This function should be deterministic and side-effect
* free because it can run many times per animation frame.
*/
effects(context: OriginChoreographyContext): OriginEffectSet;
/**
* Gesture progress required to retain the target after release.
*
* @defaultValue `0.25`
*/
readonly commitThreshold?: number;
/**
* Release velocity, in progress units per second, that commits a flick
* regardless of progress. Velocity is normalized by the gesture host's
* size, so `1` means one full host width (or height) per second.
*
* @defaultValue `0.2`
*/
readonly commitVelocity?: number;
/**
* Keep this choreography's progress-`1` effects connected after a committed
* push instead of collapsing the edge and hiding its source.
*
* This is intended for partial presentations such as drawers and inspectors
* where part of the retained source remains visible beside the target. The
* source remains mounted, visible, and inert. A back operation temporarily
* suspends the resting edge; cancelling back restores it.
*
* Only retained-history `push` actions support connected resting effects.
*
* @defaultValue `false`
*/
readonly persistAtRest?: boolean;
/**
* Settling spring after release. A drawer, a sheet, and a full-page push
* usually want different springs.
*/
readonly spring?: OriginSpring;
}
/** Damped-spring settings for settling a released operation. */
export interface OriginSpring {
/**
* Pull toward the resting position. Higher settles faster.
*
* @defaultValue `280`
*/
readonly stiffness?: number;
/**
* Resistance to motion. Lower overshoots and bounces.
*
* @defaultValue `30`
*/
readonly damping?: number;
}
/**
* 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;
}
/** Distinguish a choreography from a plain options object. */
export const isChoreography = (value: unknown): value is OriginChoreography => {
return (
typeof value === "object" &&
value !== null &&
"effects" in value &&
typeof value.effects === "function"
);
};
/**
* 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,
};
}
/** Whether a transform contributes anything to a composed transform list. */
function isComposable(transform: unknown): transform is string {
return (
typeof transform === "string" && transform !== "" && transform !== "none"
);
}
/** Result of collapsing an ordered list of effects into one style. */
export interface ComposedEffects {
/** Merged style with the composed `transform` and `opacity` applied. */
readonly style: CSSProperties;
/** Sum of every effect's relative layer contribution. */
readonly layer: number;
}
/**
* Collapse independent effect layers, ordered from the outermost inherited
* frame to the most local one, into a single style.
*
* Transforms are concatenated and opacities multiplied, including `transform`
* and numeric `opacity` supplied through `effect.style`. Every other `style`
* property uses local-last precedence over `base` and earlier effects.
*/
export const composeEffects = (
effects: readonly OriginEffect[],
base: CSSProperties = {},
): ComposedEffects => {
const style: CSSProperties = { ...base };
const transforms: string[] = [];
let opacity = 1;
let layer = 0;
for (const effect of effects) {
if (isComposable(effect.transform)) transforms.push(effect.transform);
if (effect.opacity !== undefined) opacity *= effect.opacity;
layer += effect.layer ?? 0;
if (effect.style) {
Object.assign(style, effect.style);
if (isComposable(effect.style.transform))
transforms.push(effect.style.transform);
if (effect.style.opacity !== undefined) {
const styleOpacity = Number(effect.style.opacity);
if (Number.isFinite(styleOpacity)) opacity *= styleOpacity;
}
}
}
// Assign the composed properties last so no single effect's `style` can
// replace transform work inherited from other frames.
style.transform = transforms.length ? transforms.join(" ") : "none";
style.opacity = String(clamp01(opacity));
return { style, layer };
};
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",
effects: ({ progress }) => ({
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 `back()` action selects retained-history behavior.
*/
export const slideRight = defineOriginChoreography({
name: "slide-right",
effects: ({ progress }) => ({
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 }) => ({
source: { opacity: 1 - progress },
target: { opacity: progress },
}),
});
@@ -0,0 +1,35 @@
<script setup lang="ts">
import type { OriginGestureProps } from "../gesture/definition";
import { useOriginGesture } from "../gesture/recognizer";
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. A new `gesture` prop applies from
* the next pointer-down.
*/
const gesture = useOriginGesture(() => props.gesture);
</script>
<template>
<component
:is="as"
:ref="gesture.ref"
v-bind="{ ...$attrs, ...gesture.listeners }"
class="nvo-gesture"
:style="gesture.style"
@pointerdown="gesture.onPointerdown"
@pointermove="gesture.onPointermove"
@pointerup="gesture.onPointerup"
@pointercancel="gesture.onPointercancel"
@lostpointercapture="gesture.onLostpointercapture"
>
<slot />
</component>
</template>
@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { OriginGestureSurfaceProps } from "../gesture/definition";
import { useOriginGestures } from "../gesture/recognizer";
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 surface = useOriginGestures(() => props.gestures);
</script>
<template>
<component
:is="as"
:ref="surface.ref"
v-bind="{ ...$attrs, ...surface.listeners }"
class="nvo-gesture"
:style="[$attrs.style, surface.style]"
@pointerdown="surface.onPointerdown"
@pointermove="surface.onPointermove"
@pointerup="surface.onPointerup"
@pointercancel="surface.onPointercancel"
@lostpointercapture="surface.onLostpointercapture"
>
<slot />
</component>
</template>
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, provide, ref, watchEffect } from "vue";
import { originNodeScopeKey } from "../scene/scene";
import type { OriginScene, OriginSceneNode } from "../scene/types";
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>
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, watchEffect } from "vue";
import type { OriginSceneProps } from "../scene/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>
+628
View File
@@ -0,0 +1,628 @@
import type { CSSProperties, MaybeRefOrGetter } from "vue";
import type { OriginChoreography } from "../choreography";
import type { OriginNavigationIntent } from "../navigation";
import type { OriginContext } from "../scene/types";
import type { OriginRect } from "../shared";
import { isAtScrollEdge, withinEdge } from "./recognizer";
/*
* What a gesture is: the public gesture types, component props, and the
* immutable builder that produces executable definitions. Running them is
* the recognizer's job.
*/
/** Direction in which pointer movement advances operation progress. */
export type OriginGestureDirection = "left" | "right" | "up" | "down";
/** Physical side of the gesture host used to admit pointer-down. */
export type OriginGestureEdge = "left" | "right" | "top" | "bottom";
/**
* CSS length accepted by an edge-based gesture start rule.
*
* Numbers are interpreted as CSS pixels. Strings may use normal CSS lengths,
* percentages, `calc()`, or `clamp()`, and are resolved against the gesture
* host when pointer-down occurs.
*/
export type OriginGestureDistance = number | string;
/** A pointer position measured in viewport and gesture-host coordinates. */
export interface OriginGesturePoint {
/** Viewport-relative horizontal position. */
readonly clientX: number;
/** Viewport-relative vertical position. */
readonly clientY: number;
/** Horizontal position relative to the gesture host's left edge. */
readonly localX: number;
/** Vertical position relative to the gesture host's top edge. */
readonly localY: number;
}
/** Context supplied to a custom `.from.when()` start predicate. */
export interface OriginGestureStartContext {
/** Native pointer-down event being considered. */
readonly event: PointerEvent;
/** Component instance from which the gesture would originate. */
readonly origin: OriginContext;
/** Element carrying the gesture's pointer handlers. */
readonly host: HTMLElement;
/** Gesture-host bounds captured at pointer-down. */
readonly bounds: OriginRect;
/** Pointer position at pointer-down. */
readonly point: OriginGesturePoint;
}
/**
* Synchronous predicate deciding whether a pointer-down may become a gesture.
*
* Directional recognition still occurs later, after movement passes the
* configured intent threshold.
*/
export type OriginGestureStartPredicate = (
context: OriginGestureStartContext,
) => boolean;
/** Start policy stored in a completed gesture definition. */
export type OriginGestureStart =
| {
/** Recognize pointer-down anywhere on the host. */
readonly kind: "anywhere";
}
| {
/** Recognize pointer-down within a CSS distance of one host side. */
readonly kind: "edge";
readonly edge: OriginGestureEdge;
readonly distance: OriginGestureDistance;
}
| {
/** Recognize pointer-down when application policy returns `true`. */
readonly kind: "when";
readonly predicate: OriginGestureStartPredicate;
}
| {
/**
* Recognize pointer-down while a scroller is at one edge. The gesture
* shares its axis with that scroller's native scrolling.
*/
readonly kind: "scroll-edge";
readonly scroller: OriginGestureScroller;
readonly edge: OriginGestureEdge;
};
/** Direction-recognition tuning accepted by `.to.left()` and its siblings. */
export interface OriginGestureDirectionOptions {
/**
* Minimum directed movement in CSS pixels before the gesture captures.
*
* @defaultValue `8`
*/
readonly threshold?: number;
/**
* Ratio by which directed movement must exceed cross-axis movement.
*
* @defaultValue `1.15`
*/
readonly axisDominance?: number;
/**
* Movement that counts as full progress (`1`), resolved at pointer-down.
* A CSS length (numbers are pixels, `%` is of the host), or a function
* returning pixels, for example to measure a drawer narrower than the host.
*
* @defaultValue the host's width (horizontal) or height (vertical)
*/
readonly distance?: OriginGestureProgressDistance;
}
/**
* Movement that counts as full progress: a CSS length, or a function that
* returns pixels, measured at pointer-down.
*/
export type OriginGestureProgressDistance =
| OriginGestureDistance
| ((context: {
readonly host: HTMLElement;
readonly bounds: OriginRect;
}) => number);
/** Values available when a custom `.complete()` policy runs on pointer-up. */
export interface OriginGestureCompletionContext {
/** Component instance that originated the gesture. */
readonly origin: OriginContext;
/** Recognized movement direction. */
readonly direction: OriginGestureDirection;
/** Normalized directed distance, clamped to `0..1`. */
readonly progress: number;
/** Latest normalized progress units per second. */
readonly velocity: number;
/** Directed movement from pointer-down in CSS pixels. */
readonly distance: number;
/** Absolute cross-axis movement from pointer-down in CSS pixels. */
readonly crossDistance: number;
/** Elapsed time since pointer-down in milliseconds. */
readonly duration: number;
/** Native pointer-up event that ended the interaction. */
readonly event: PointerEvent;
/** Element carrying the gesture's pointer handlers. */
readonly host: HTMLElement;
/** Gesture-host bounds captured at pointer-down. */
readonly bounds: OriginRect;
/** Pointer position captured at pointer-down. */
readonly start: OriginGesturePoint;
/** Pointer position at release. */
readonly current: OriginGesturePoint;
}
/** Synchronous commit/cancel policy installed by `.complete()`. */
export type OriginGestureCompletionPredicate = (
context: OriginGestureCompletionContext,
) => boolean;
/**
* Values accepted from a gesture builder's `.navigate()` factory.
*
* A promise supports lazy view selection. Stale resolutions are discarded if
* the pointer has already ended or been cancelled.
*/
export type MaybeOriginNavigationIntent =
| OriginNavigationIntent
| null
| undefined
| Promise<OriginNavigationIntent | null | undefined>;
/** Factory that resolves navigation after directional recognition succeeds. */
export type OriginGestureNavigationFactory = (
context: OriginContext,
) => MaybeOriginNavigationIntent;
/**
* Immutable, executable result of a complete gesture builder chain.
*
* Pass this object to {@link useOriginGesture}. An omitted `.from` step is
* represented as an `anywhere` start rule.
*/
export interface OriginGestureDefinition {
/** Identifies a definition produced by the {@link gesture} builder. */
readonly kind: "origin-gesture-definition";
/** Pointer-down eligibility policy. */
readonly start: OriginGestureStart;
/** Direction in which movement advances operation progress. */
readonly direction: OriginGestureDirection;
/** Directional intent recognition tuning. */
readonly recognition: Readonly<OriginGestureDirectionOptions>;
/**
* Optional release decision. When omitted, choreography thresholds decide.
*/
readonly completion?: OriginGestureCompletionPredicate;
/** Node-local destination/history resolver. */
readonly navigation: OriginGestureNavigationFactory;
/** Visual routine paired with the navigation intent. */
readonly choreography: OriginChoreography;
/** Handlers run, in order, when a release commits. See `.onCommit()`. */
readonly commitHandlers?: readonly OriginGestureCommitHandler[];
/** Handlers run when a drag starts to qualify to commit. See `.onArm()`. */
readonly armHandlers?: readonly OriginGestureCommitHandler[];
/** Handlers run when a drag stops qualifying to commit. */
readonly disarmHandlers?: readonly OriginGestureCommitHandler[];
}
/**
* Side effect run when a released gesture commits, such as haptic feedback.
*
* It runs once, synchronously at release, after the commit decision and
* before the settling animation. It receives the same context as a
* `.complete()` predicate. Errors are reported without interrupting the
* operation.
*/
export type OriginGestureCommitHandler = (
context: OriginGestureCompletionContext,
) => void;
/** Direction-selection stage shared by `gesture.to` and `.from.*().to`. */
export interface OriginGestureToBuilder {
/** Recognize leftward pointer movement. */
left(options?: OriginGestureDirectionOptions): OriginGestureDirectedBuilder;
/** Recognize rightward pointer movement. */
right(options?: OriginGestureDirectionOptions): OriginGestureDirectedBuilder;
/** Recognize upward pointer movement. */
up(options?: OriginGestureDirectionOptions): OriginGestureDirectedBuilder;
/** Recognize downward pointer movement. */
down(options?: OriginGestureDirectionOptions): OriginGestureDirectedBuilder;
}
/** Stage produced after a `.from` policy has been selected. */
export interface OriginGestureFromSelection {
/** Select the direction that advances this gesture. */
readonly to: OriginGestureToBuilder;
}
/** Optional pointer-down policy exposed at the start of a gesture chain. */
export interface OriginGestureFromBuilder {
/** Admit pointer-down within `distance` of the host's left edge. */
left(distance: OriginGestureDistance): OriginGestureFromSelection;
/** Admit pointer-down within `distance` of the host's right edge. */
right(distance: OriginGestureDistance): OriginGestureFromSelection;
/** Admit pointer-down within `distance` of the host's top edge. */
top(distance: OriginGestureDistance): OriginGestureFromSelection;
/** Admit pointer-down within `distance` of the host's bottom edge. */
bottom(distance: OriginGestureDistance): OriginGestureFromSelection;
/** Admit pointer-down anywhere on the gesture host. */
anywhere(): OriginGestureFromSelection;
/** Admit pointer-down when a synchronous application predicate succeeds. */
when(predicate: OriginGestureStartPredicate): OriginGestureFromSelection;
/**
* Admit pointer-down only while `scroller` is scrolled to `edge`, for
* example drag-down-to-dismiss only at the top of a list. Movement the
* other way is left to the scroller. See {@link atScrollEdge}.
*/
scrollEdge(
scroller: OriginGestureScroller,
edge: OriginGestureEdge,
): OriginGestureFromSelection;
}
/**
* A scroll container: an element, a ref or getter returning one, or a CSS
* selector resolved inside the gesture host.
*/
export type OriginGestureScroller =
MaybeRefOrGetter<Element | null | undefined> | string;
/** Builder stage after direction is known and completion remains optional. */
export interface OriginGestureDirectedBuilder {
/** Override the choreography's default release decision. */
complete(
predicate: OriginGestureCompletionPredicate,
): OriginGestureCompletedBuilder;
/** Select the target/history mutation while retaining default completion. */
navigate(
factory: OriginGestureNavigationFactory,
): OriginGestureNavigationBuilder;
/** Run `handler` when a release commits. Handlers accumulate in order. */
onCommit(handler: OriginGestureCommitHandler): OriginGestureDirectedBuilder;
/**
* Run `handler` during a drag when releasing would start to commit, for
* example for a "release now" haptic. See `.onArm()` in the API reference.
*/
onArm(handler: OriginGestureCommitHandler): OriginGestureDirectedBuilder;
/** Run `handler` during a drag when releasing would no longer commit. */
onDisarm(handler: OriginGestureCommitHandler): OriginGestureDirectedBuilder;
}
/** Builder stage after a custom completion policy has been selected. */
export interface OriginGestureCompletedBuilder {
/** Select the target/history mutation performed on commit. */
navigate(
factory: OriginGestureNavigationFactory,
): OriginGestureNavigationBuilder;
/** Run `handler` when a release commits. Handlers accumulate in order. */
onCommit(handler: OriginGestureCommitHandler): OriginGestureCompletedBuilder;
/**
* Run `handler` during a drag when releasing would start to commit, for
* example for a "release now" haptic. See `.onArm()` in the API reference.
*/
onArm(handler: OriginGestureCommitHandler): OriginGestureCompletedBuilder;
/** Run `handler` during a drag when releasing would no longer commit. */
onDisarm(handler: OriginGestureCommitHandler): OriginGestureCompletedBuilder;
}
/** Final builder stage waiting for visual choreography. */
export interface OriginGestureNavigationBuilder {
/** Attach visual choreography and produce an executable definition. */
animate(choreography: OriginChoreography): OriginGestureDefinition;
/** Run `handler` when a release commits. Handlers accumulate in order. */
onCommit(handler: OriginGestureCommitHandler): OriginGestureNavigationBuilder;
/**
* Run `handler` during a drag when releasing would start to commit, for
* example for a "release now" haptic. See `.onArm()` in the API reference.
*/
onArm(handler: OriginGestureCommitHandler): OriginGestureNavigationBuilder;
/** Run `handler` during a drag when releasing would no longer commit. */
onDisarm(handler: OriginGestureCommitHandler): OriginGestureNavigationBuilder;
}
/** Root of the immutable fluent gesture-definition API. */
export interface OriginGestureBuilder {
/** Optionally constrain where pointer-down may begin. */
readonly from: OriginGestureFromBuilder;
/**
* Select movement direction with an implicit `from.anywhere()` start.
*/
readonly to: OriginGestureToBuilder;
}
/**
* DOM bindings returned by {@link useOriginGesture}.
*
* Attach the pointer handlers to the same `HTMLElement` (and `onTouchmove`
* when {@link needsTouchmove} is true), and apply
* {@link style}: its `touch-action` keeps native scrolling on every axis no
* gesture claims.
*/
export interface OriginGestureBinding {
/**
* Required size and `touch-action` styles for the gesture host. Reading it
* inside a render function tracks changes to the gesture definitions.
*/
readonly style: Readonly<CSSProperties>;
/** Pointer-down handler that records a potentially eligible gesture. */
readonly onPointerdown: (event: PointerEvent) => void;
/** Pointer-move handler that recognizes and updates the operation. */
readonly onPointermove: (event: PointerEvent) => void;
/** Pointer-up handler that commits or cancels using progress and velocity. */
readonly onPointerup: (event: PointerEvent) => void;
/** Pointer-cancel handler that abandons any captured operation. */
readonly onPointercancel: () => void;
/**
* Lost-capture handler. Cancels a captured gesture whose pointer capture
* ends without a pointer-up, so the operation never stays stuck mid-way.
*/
readonly onLostpointercapture: (event: PointerEvent) => void;
/**
* Touch-move handler for gestures that share an axis with native scrolling
* (`.from.scrollEdge()`): it cancels scrolling for a touch such a gesture
* claims, and leaves every other touch to the browser. Attach it as a
* non-passive listener (Vue's `@touchmove` is), and only when
* {@link needsTouchmove} is true.
*/
readonly onTouchmove: (event: TouchEvent) => void;
/**
* Whether any gesture shares an axis with scrolling, so `onTouchmove` must
* be attached. Leave it off otherwise: a non-passive touch listener makes
* the browser wait for JavaScript on every touch move, which costs frames.
*/
readonly needsTouchmove: boolean;
/**
* Extra listeners the host needs, as Vue props: `v-bind` them on the host.
* Scroll-edge gestures track their scroller here (a passive scroll
* listener), plus `onTouchmove` where the browser lacks directional
* `touch-action`. Empty for ordinary gestures.
*/
readonly listeners: Readonly<Record<string, (event: Event) => void>>;
/** Function ref for the host element: `:ref="binding.ref"`. */
readonly ref: (element: unknown) => void;
}
// Component props ----------------------------------------------------------
/** Public props accepted by the `OriginGesture` convenience component. */
export interface OriginGestureProps {
/**
* Native HTML tag rendered as the gesture host.
*
* @defaultValue `"div"`
*/
as?: string;
/**
* Immutable definition produced by the {@link gesture} builder. Replacing
* it takes effect from the next pointer-down; a gesture in progress keeps
* the definition it started with.
*/
gesture: OriginGestureDefinition;
}
/** Public props accepted by the multi-definition gesture surface component. */
export interface OriginGestureSurfaceProps {
/**
* Native HTML tag rendered as the shared gesture host.
*
* @defaultValue `"div"`
*/
as?: string;
/**
* Complete immutable definitions installed on the shared host.
*
* The surface adds no start, direction, completion, navigation, or animation
* policy. Definitions should be created by the owning page component. The
* list may change; a gesture in progress keeps the definition it started
* with.
*/
gestures: readonly OriginGestureDefinition[];
}
// Builder ------------------------------------------------------------------
/*
* Immutable fluent builder. Each stage is a frozen object holding the choices
* made so far in a frozen draft; the final `.animate()` produces a frozen
* definition. `.onCommit()` is available at every stage after the direction
* and returns the same stage with the handler appended.
*
* gesture[.from.<start>].to.<direction>()[.complete()].navigate().animate()
*/
/** Choices accumulated by the builder before `.animate()`. */
interface GestureDraft {
readonly start: OriginGestureStart;
readonly direction: OriginGestureDirection;
readonly recognition: Readonly<OriginGestureDirectionOptions>;
readonly completion?: OriginGestureCompletionPredicate;
readonly commitHandlers: readonly OriginGestureCommitHandler[];
readonly armHandlers: readonly OriginGestureCommitHandler[];
readonly disarmHandlers: readonly OriginGestureCommitHandler[];
}
type HandlerList = "commitHandlers" | "armHandlers" | "disarmHandlers";
/**
* `.onCommit()`, `.onArm()`, and `.onDisarm()` for a stage: each rebuilds the
* same stage from the draft with the handler appended.
*/
function hookMethods<Draft extends GestureDraft, Stage>(
draft: Draft,
stage: (draft: Draft) => Stage,
) {
const append = (list: HandlerList) => (handler: OriginGestureCommitHandler) =>
stage(
Object.freeze({
...draft,
[list]: Object.freeze([...draft[list], handler]),
}),
);
return {
onCommit: append("commitHandlers"),
onArm: append("armHandlers"),
onDisarm: append("disarmHandlers"),
};
}
function navigationStage(
draft: GestureDraft & { readonly navigation: OriginGestureNavigationFactory },
): OriginGestureNavigationBuilder {
return Object.freeze({
animate: (choreography: OriginChoreography): OriginGestureDefinition =>
Object.freeze({
kind: "origin-gesture-definition",
start: draft.start,
direction: draft.direction,
recognition: draft.recognition,
completion: draft.completion,
navigation: draft.navigation,
choreography,
commitHandlers: draft.commitHandlers,
armHandlers: draft.armHandlers,
disarmHandlers: draft.disarmHandlers,
}),
...hookMethods(draft, navigationStage),
});
}
const navigateFrom =
(draft: GestureDraft) => (navigation: OriginGestureNavigationFactory) =>
navigationStage(Object.freeze({ ...draft, navigation }));
function completedStage(draft: GestureDraft): OriginGestureCompletedBuilder {
return Object.freeze({
navigate: navigateFrom(draft),
...hookMethods(draft, completedStage),
});
}
function directedStage(draft: GestureDraft): OriginGestureDirectedBuilder {
return Object.freeze({
complete: (completion: OriginGestureCompletionPredicate) =>
completedStage(Object.freeze({ ...draft, completion })),
navigate: navigateFrom(draft),
...hookMethods(draft, directedStage),
});
}
function toStage(start: OriginGestureStart): OriginGestureToBuilder {
const direct =
(direction: OriginGestureDirection) =>
(options: OriginGestureDirectionOptions = {}) =>
directedStage(
Object.freeze({
start,
direction,
recognition: Object.freeze({ ...options }),
commitHandlers: Object.freeze([]),
armHandlers: Object.freeze([]),
disarmHandlers: Object.freeze([]),
}),
);
return Object.freeze({
left: direct("left"),
right: direct("right"),
up: direct("up"),
down: direct("down"),
});
}
function fromStage(start: OriginGestureStart): OriginGestureFromSelection {
return Object.freeze({ to: toStage(Object.freeze(start)) });
}
const fromEdge =
(edge: OriginGestureEdge) => (distance: OriginGestureDistance) =>
fromStage({ kind: "edge", edge, distance });
const fromBuilder: OriginGestureFromBuilder = Object.freeze({
left: fromEdge("left"),
right: fromEdge("right"),
top: fromEdge("top"),
bottom: fromEdge("bottom"),
anywhere: () => fromStage({ kind: "anywhere" }),
when: (predicate: OriginGestureStartPredicate) =>
fromStage({ kind: "when", predicate }),
scrollEdge: (scroller: OriginGestureScroller, edge: OriginGestureEdge) =>
fromStage({ kind: "scroll-edge", scroller, edge }),
});
// Start rules ---------------------------------------------------------------
/*
* Composable `.from.when()` predicates. Each returns a start predicate, so
* they combine with anyOf() and allOf():
*
* gesture.from
* .when(anyOf(startedIn(".sheet-handle"), atScrollEdge(list, "top")))
* .to.down()
*/
/** Admit pointer-down on an element matching `selector`, or inside one. */
export const startedIn =
(selector: string): OriginGestureStartPredicate =>
({ event }) =>
event.target instanceof Element && event.target.closest(selector) !== null;
/** Admit pointer-down within `distance` of one side of the gesture host. */
export const nearEdge =
(
edge: OriginGestureEdge,
distance: OriginGestureDistance,
): OriginGestureStartPredicate =>
({ host, bounds, point }) =>
withinEdge(edge, distance, host, bounds, point);
/**
* Admit pointer-down while `scroller` is scrolled all the way to `edge`.
* A scroller that is missing (not yet mounted) counts as at the edge.
*/
export const atScrollEdge =
(
scroller: OriginGestureScroller,
edge: OriginGestureEdge,
): OriginGestureStartPredicate =>
({ host }) =>
isAtScrollEdge(scroller, edge, host);
/** Admit pointer-down when any of `predicates` does. */
export const anyOf =
(...predicates: OriginGestureStartPredicate[]): OriginGestureStartPredicate =>
(context) =>
predicates.some((predicate) => predicate(context));
/** Admit pointer-down only when all of `predicates` do. */
export const allOf =
(...predicates: OriginGestureStartPredicate[]): OriginGestureStartPredicate =>
(context) =>
predicates.every((predicate) => predicate(context));
/**
* 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: toStage(Object.freeze({ kind: "anywhere" })),
});
+919
View File
@@ -0,0 +1,919 @@
import {
computed,
onScopeDispose,
shallowRef,
toValue,
type MaybeRefOrGetter,
} from "vue";
import type { OriginAction } from "../navigation";
import { DEFAULT_COMMIT_THRESHOLD, OriginSceneError } from "../scene/graph";
import { useOrigin, type UseOrigin } from "../scene/scene";
import type { OriginContext, OriginOperationHandle } from "../scene/types";
import { clamp01, measureRect, warn, type OriginRect } from "../shared";
import type {
OriginGestureBinding,
OriginGestureDefinition,
OriginGestureCompletionContext,
OriginGestureDirection,
OriginGestureDistance,
OriginGestureEdge,
OriginGesturePoint,
OriginGestureProgressDistance,
OriginGestureScroller,
OriginGestureStart,
} from "./definition";
/*
* Running gestures: pointer-down admission, movement tracking, and the
* recognizer that turns one host's pointer events into a scene operation.
*/
// Direction ----------------------------------------------------------------
export const isHorizontal = (direction: OriginGestureDirection): boolean => {
return direction === "left" || direction === "right";
};
/** Component of a movement `(dx, dy)` along `direction`. */
export const directedDistance = (
direction: OriginGestureDirection,
dx: number,
dy: number,
): number => {
switch (direction) {
case "left":
return -dx;
case "right":
return dx;
case "up":
return -dy;
case "down":
return dy;
}
};
/** Absolute movement perpendicular to `direction`. */
export const crossDistance = (
direction: OriginGestureDirection,
dx: number,
dy: number,
): number => {
return isHorizontal(direction) ? Math.abs(dy) : Math.abs(dx);
};
/** Whether a gesture shares its axis with native scrolling. */
export const sharesScroll = (definition: OriginGestureDefinition): boolean =>
definition.start.kind === "scroll-edge";
/**
* Whether the browser supports direction-specific `touch-action` values
* (`pan-up`, `pan-down`, …). Chromium does; where it is missing (Safari),
* scroll-edge gestures fall back to claiming touches from JavaScript.
*/
export const supportsDirectionalPan = (): boolean =>
typeof CSS !== "undefined" &&
typeof CSS.supports === "function" &&
CSS.supports("touch-action", "pan-down");
type PanDirection = "left" | "right" | "up" | "down";
/**
* The pan a finger moving `direction` would start. A finger moving down pans
* the content up (toward its top), which is what pull-to-refresh reserves.
*/
const PAN_OF: Record<OriginGestureDirection, PanDirection> = {
down: "up",
up: "down",
left: "right",
right: "left",
};
/**
* CSS `touch-action` for a gesture host. The browser keeps every pan that no
* gesture needs, so native scrolling never waits for JavaScript:
*
* - a gesture that owns its axis removes that axis (`pan-y` remains for a
* horizontal gesture);
* - a gesture sharing its axis with a scroller (`.from.scrollEdge()`) removes
* only the one pan its finger direction would start, and only while the
* scroller is at its edge (`pan-x pan-down` for drag-down at the top), the
* pull-to-refresh pattern from the Pointer Events specification.
*
* @param atEdge - Whether a scroll-edge definition's scroller is at its edge.
* @param directional - Whether directional pan values are supported; without
* them, shared axes stay fully pannable and `onTouchmove` claims touches.
*/
export const touchActionFor = (
definitions: Iterable<Pick<OriginGestureDefinition, "direction" | "start">>,
atEdge: (
definition: Pick<OriginGestureDefinition, "start">,
) => boolean = () => false,
directional = supportsDirectionalPan(),
): string => {
const pans = new Set<PanDirection>(["left", "right", "up", "down"]);
let any = false;
for (const definition of definitions) {
any = true;
if (definition.start.kind === "scroll-edge") {
if (directional && atEdge(definition))
pans.delete(PAN_OF[definition.direction]);
continue;
}
const pair: PanDirection[] = isHorizontal(definition.direction)
? ["left", "right"]
: ["up", "down"];
for (const pan of pair) pans.delete(pan);
}
if (!any) return "auto";
if (pans.size === 4) return "manipulation";
if (pans.size === 0) return "none";
const axis = (a: PanDirection, b: PanDirection, both: string) =>
pans.has(a) && pans.has(b)
? [both]
: pans.has(a)
? [`pan-${a}`]
: pans.has(b)
? [`pan-${b}`]
: [];
return [
...axis("left", "right", "pan-x"),
...axis("up", "down", "pan-y"),
].join(" ");
};
// Pointer-down admission ----------------------------------------------------
/*
* Pointer-down admission: which events may begin a gesture at all.
*/
const IGNORED_TARGETS =
'[data-origin-gesture="ignore"], input, textarea, select, option, [contenteditable="true"]';
/** Form controls and opted-out subtrees never start gestures. */
export const isIgnoredTarget = (target: EventTarget | null): boolean => {
return (
!(target instanceof Element) || Boolean(target.closest(IGNORED_TARGETS))
);
};
export const 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,
};
};
const nonNegative = (value: number) =>
Number.isFinite(value) ? Math.max(0, value) : 0;
const PIXELS = /^\s*(-?(?:\d+\.?\d*|\.\d+))px\s*$/;
const PERCENT = /^\s*(-?(?:\d+\.?\d*|\.\d+))%\s*$/;
/**
* Resolve a CSS length against a box with the gesture host's size.
*
* Numbers, pixel lengths, and percentages are computed directly. Anything
* else (`rem`, viewport units, `calc()`, `clamp()`) is measured with a
* short-lived off-screen box so the browser resolves it exactly. This runs
* only during pointer-down for edge-constrained definitions.
*/
export const resolveCssDistance = (
distance: OriginGestureDistance,
axis: "horizontal" | "vertical",
host: HTMLElement,
bounds: OriginRect,
): number => {
if (typeof distance === "number") return nonNegative(distance);
const pixels = PIXELS.exec(distance);
if (pixels) return nonNegative(Number(pixels[1]));
const percent = PERCENT.exec(distance);
if (percent) {
const size = axis === "horizontal" ? bounds.width : bounds.height;
return nonNegative((Number(percent[1]) / 100) * size);
}
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 rect = probe.getBoundingClientRect();
container.remove();
return nonNegative(axis === "horizontal" ? rect.width : rect.height);
};
/** Whether a pointer-down satisfies a definition's start policy. */
export const matchesStart = (
start: OriginGestureStart,
event: PointerEvent,
host: HTMLElement,
origin: OriginContext,
bounds: OriginRect,
): boolean => {
if (start.kind === "anywhere") return true;
const point = pointOf(event, bounds);
if (start.kind === "when")
return start.predicate({ event, origin, host, bounds, point });
if (start.kind === "scroll-edge")
return isAtScrollEdge(start.scroller, start.edge, host);
return withinEdge(start.edge, start.distance, host, bounds, point);
};
/**
* Whether `scroller` is scrolled all the way to `edge`. A scroller that is
* missing (not yet mounted) counts as at the edge.
*/
export const isAtScrollEdge = (
scroller: OriginGestureScroller,
edge: OriginGestureEdge,
host: HTMLElement,
): boolean => {
const element =
typeof scroller === "string"
? host.querySelector(scroller)
: toValue(scroller);
if (!element) return true;
const { scrollTop, scrollLeft, scrollHeight, scrollWidth } = element;
const { clientHeight, clientWidth } = element;
switch (edge) {
case "top":
return scrollTop <= 0;
case "bottom":
return scrollTop + clientHeight >= scrollHeight - 1;
case "left":
return scrollLeft <= 0;
case "right":
return scrollLeft + clientWidth >= scrollWidth - 1;
}
};
/** Whether `point` lies within `distance` of one side of the host. */
export const withinEdge = (
edge: OriginGestureEdge,
distance: OriginGestureDistance,
host: HTMLElement,
bounds: OriginRect,
point: OriginGesturePoint,
): boolean => {
const horizontal = edge === "left" || edge === "right";
const limit = resolveCssDistance(
distance,
horizontal ? "horizontal" : "vertical",
host,
bounds,
);
switch (edge) {
case "left":
return point.localX >= 0 && point.localX <= limit;
case "right":
return (
point.localX <= bounds.width && bounds.width - point.localX <= limit
);
case "top":
return point.localY >= 0 && point.localY <= limit;
case "bottom":
return (
point.localY <= bounds.height && bounds.height - point.localY <= limit
);
}
};
// Movement tracking ---------------------------------------------------------
type PointerSample = Pick<PointerEvent, "clientX" | "clientY" | "timeStamp">;
/** Velocity is the trend of the samples in this many recent milliseconds. */
const VELOCITY_WINDOW = 100;
/** Shortest span, in ms, velocity is measured over, so bursts stay finite. */
const MIN_VELOCITY_SPAN = 8;
/**
* Measures one pointer sequence's movement along a gesture direction.
*
* Progress and velocity are normalized by the gesture distance (by default
* the host's size along the gesture axis), so `1` means one full distance.
*/
export class DirectionalTracker {
/** Directed distance from the start, in CSS pixels. */
distance = 0;
/** Absolute cross-axis distance from the start, in CSS pixels. */
crossDistance = 0;
/** Directed distance ÷ gesture distance, unclamped so it can overshoot. */
rawProgress = 0;
/**
* Directed velocity in progress units per second: the least-squares trend
* of the last {@link VELOCITY_WINDOW} ms, so uneven sample timing and a
* final held-still moment do not distort a flick.
*/
velocity = 0;
private readonly samples: { time: number; position: number }[];
constructor(
private readonly direction: OriginGestureDirection,
private readonly start: PointerSample,
/** Gesture distance in CSS pixels, read at every sample. */
private readonly axisSize: () => number,
) {
this.samples = [{ time: start.timeStamp, position: 0 }];
}
/** Directed distance ÷ gesture distance, clamped to `0..1`. */
get progress(): number {
return clamp01(this.rawProgress);
}
/** Movement from the start without recording a sample. */
displacement(event: Pick<PointerSample, "clientX" | "clientY">) {
const dx = event.clientX - this.start.clientX;
const dy = event.clientY - this.start.clientY;
return {
distance: directedDistance(this.direction, dx, dy),
crossDistance: crossDistance(this.direction, dx, dy),
};
}
/** Record a sample, updating distance, progress, and velocity. */
sample(event: PointerSample): void {
const { distance, crossDistance } = this.displacement(event);
this.distance = distance;
this.crossDistance = crossDistance;
const size = Math.max(1, this.axisSize());
this.rawProgress = distance / size;
const samples = this.samples;
samples.push({ time: event.timeStamp, position: distance });
// Keep the window, but always at least two samples to measure between.
const since = event.timeStamp - VELOCITY_WINDOW;
while (samples.length > 2 && samples[1]!.time <= since) samples.shift();
this.velocity = (DirectionalTracker.trend(samples) * 1000) / size;
}
/** Least-squares slope of position over time, in pixels per ms. */
private static trend(samples: readonly { time: number; position: number }[]) {
const first = samples[0]!;
const last = samples[samples.length - 1]!;
if (last.time - first.time < MIN_VELOCITY_SPAN)
return (last.position - first.position) / MIN_VELOCITY_SPAN;
const meanTime =
samples.reduce((sum, { time }) => sum + time, 0) / samples.length;
const meanPosition =
samples.reduce((sum, { position }) => sum + position, 0) / samples.length;
let covariance = 0;
let variance = 0;
for (const { time, position } of samples) {
covariance += (time - meanTime) * (position - meanPosition);
variance += (time - meanTime) ** 2;
}
return covariance / variance;
}
}
// Recognizer ----------------------------------------------------------------
/** Directed movement in CSS pixels required before a gesture captures. */
const DEFAULT_THRESHOLD = 8;
/** Ratio by which directed movement must exceed cross-axis movement. */
const DEFAULT_AXIS_DOMINANCE = 1.15;
/** Resolve a definition's progress distance at pointer-down, in pixels. */
export const resolveProgressDistance = (
distance: OriginGestureProgressDistance | undefined,
direction: OriginGestureDirection,
host: HTMLElement,
bounds: OriginRect,
): (() => number) => {
const horizontal = isHorizontal(direction);
if (distance === undefined)
return () => (horizontal ? host.clientWidth : host.clientHeight);
const pixels =
typeof distance === "function"
? distance({ host, bounds })
: resolveCssDistance(
distance,
horizontal ? "horizontal" : "vertical",
host,
bounds,
);
return () => pixels;
};
/** The origin capabilities a recognizer needs. */
export type RecognizerOrigin = Pick<UseOrigin, "context" | "begin">;
/** State of one admitted pointer sequence, from pointer-down to release. */
interface PointerSession {
/** Definition current at pointer-down; later changes do not affect it. */
readonly definition: OriginGestureDefinition;
readonly pointerId: number;
readonly host: HTMLElement;
readonly bounds: OriginRect;
/** Origin context captured at pointer-down. */
readonly origin: OriginContext;
readonly start: OriginGesturePoint;
readonly startTime: number;
readonly tracker: DirectionalTracker;
/**
* For a gesture sharing its axis with scrolling: whether it has claimed
* this touch from native scrolling (`ours`) or not yet decided.
*/
scroll: "pending" | "ours";
/** Latest pointer position, for the touch-move decision. */
last: { readonly clientX: number; readonly clientY: number };
/** Movement passed the recognition threshold and the pointer is captured. */
captured: boolean;
/** Releasing now would commit; drives `.onArm()` and `.onDisarm()`. */
armed: boolean;
/** The scene operation, once navigation resolved and `begin()` was called. */
operation: Promise<OriginOperationHandle | null> | null;
/**
* The operation's handle once `begin()` resolved, so pointer-up can finish
* it synchronously, inside the event, without awaiting the promise.
*/
handle: OriginOperationHandle | null;
}
/**
* Turns one host's pointer events into a scene operation.
*
* Lifecycle of a session:
*
* 1. **down**: an eligible primary pointer opens a session.
* 2. **move**: once directed movement passes the threshold, the pointer is
* captured and `.navigate()` is resolved; a non-null intent begins an
* operation, while a declined one closes the session. Every later move
* updates the operation's progress.
* 3. **up**: the session closes and the operation finishes, using the
* definition's `.complete()` predicate when present.
* 4. **cancel**: pointer-cancel, lost pointer capture, a new primary
* pointer-down, or disposal closes the session and cancels its operation.
*
* Navigation and `begin()` are asynchronous. Each continuation checks that
* its session is still current, so a stale resolution never creates or
* updates an orphan operation.
*/
export class GestureRecognizer {
private session: PointerSession | null = null;
constructor(
/** Reads the current definition; it may change between sessions. */
private readonly definitionOf: () => OriginGestureDefinition,
private readonly origin: RecognizerOrigin,
) {}
/**
* Called for each `touchmove`: whether native scrolling must be cancelled.
*
* Only gestures that share an axis with scrolling take part. The browser
* sends its first cancelable `touchmove` after roughly 15–20px of movement,
* so the decision uses all of that movement: along this gesture's axis and
* direction claims the touch; clearly elsewhere lets it go to the browser
* (which then scrolls and sends `pointercancel`); unclear holds it for one
* more move.
*/
claimTouch(): boolean {
const session = this.session;
if (!session || !sharesScroll(session.definition)) return false;
if (session.captured || session.scroll === "ours") return true;
const { distance, crossDistance } = session.tracker.displacement(
session.last,
);
const dominance =
session.definition.recognition.axisDominance ?? DEFAULT_AXIS_DOMINANCE;
const along = Math.abs(distance);
if (distance > 0 && along >= crossDistance * dominance) {
session.scroll = "ours";
return true;
}
if (
along >= crossDistance * dominance ||
crossDistance >= along * dominance
) {
this.session = null;
return false;
}
return true;
}
pointerDown(event: PointerEvent): void {
if (!event.isPrimary) return;
// Only one primary pointer exists at a time, so any session still open
// lost its pointer-up (for example, released outside the window).
void this.abandon();
const host = event.currentTarget;
if (
event.button !== 0 ||
!(host instanceof HTMLElement) ||
isIgnoredTarget(event.target)
)
return;
const definition = this.definitionOf();
const bounds = measureRect(host);
const origin = this.origin.context.value;
if (!matchesStart(definition.start, event, host, origin, bounds)) return;
// The component containing this declaration is the operation's origin.
event.stopPropagation();
this.session = {
definition,
pointerId: event.pointerId,
host,
bounds,
origin,
start: pointOf(event, bounds),
startTime: event.timeStamp,
tracker: new DirectionalTracker(
definition.direction,
event,
resolveProgressDistance(
definition.recognition.distance,
definition.direction,
host,
bounds,
),
),
scroll: "pending",
last: { clientX: event.clientX, clientY: event.clientY },
captured: false,
armed: false,
operation: null,
handle: null,
};
}
async pointerMove(event: PointerEvent): Promise<void> {
const session = this.sessionFor(event);
if (!session) return;
session.last = { clientX: event.clientX, clientY: event.clientY };
if (!session.captured) {
if (!GestureRecognizer.recognizes(session, event)) return;
session.captured = true;
session.host.setPointerCapture?.(session.pointerId);
event.preventDefault();
session.tracker.sample(event);
this.updateArming(session, event);
const action = await this.resolveAction(session.definition);
// The pointer may have been released or cancelled meanwhile.
if (this.session !== session) return;
if (!action) {
this.session = null;
return;
}
session.operation = this.origin.begin(action).then(
(handle) => (session.handle = handle),
(error) => {
// Another recognizer on this origin won the race; that is expected.
if (!(
error instanceof OriginSceneError && error.code === "origin-busy"
))
warn("A gesture could not begin its operation.", error);
return null;
},
);
} else {
event.preventDefault();
session.tracker.sample(event);
this.updateArming(session, event);
}
// Progress is read after the await so the latest buffered sample wins.
const pending = session.operation;
const handle = pending ? await pending : null;
if (this.session === session && session.operation === pending)
handle?.update(session.tracker.rawProgress, session.tracker.velocity);
}
async pointerUp(event: PointerEvent): Promise<void> {
const session = this.sessionFor(event);
if (!session) return;
this.session = null;
if (!session.captured) return;
const { tracker, definition } = session;
tracker.sample(event);
const context = GestureRecognizer.contextOf(session, event);
const commit = definition.completion?.(context);
const { rawProgress: progress, velocity } = tracker;
// Usually the operation began long ago: finish it without awaiting, so
// commit handlers run inside this pointer-up (iOS only allows haptics,
// audio, and clipboard writes during the user's gesture).
const handle =
session.handle ?? (session.operation ? await session.operation : null);
if (!handle) return;
handle.update(progress, velocity);
// finish() fixes the outcome synchronously, before the spring settles.
const settled = handle.finish(
commit === undefined ? undefined : { commit },
);
if (handle.intent === "commit")
GestureRecognizer.notify(definition.commitHandlers ?? [], context);
await settled;
}
/**
* The host lost pointer capture without a pointer-up; cancel like
* pointer-cancel. The event bubbles, and taking capture makes the touched
* child lose its implicit touch capture, so only the host's own loss counts.
*/
pointerCaptureLost(event: PointerEvent): Promise<void> {
const session = this.sessionFor(event);
return session && event.target === session.host
? this.abandon()
: Promise.resolve();
}
pointerCancel(): Promise<void> {
return this.abandon();
}
/** Close the current session, cancelling its operation if captured. */
async abandon(): Promise<void> {
const session = this.session;
this.session = null;
if (!session?.captured) return;
const handle = session.operation ? await session.operation : null;
await handle?.cancel();
}
/** Fire `.onArm()` / `.onDisarm()` when the release decision would flip. */
private updateArming(session: PointerSession, event: PointerEvent) {
const { definition, tracker } = session;
const armHandlers = definition.armHandlers ?? [];
const disarmHandlers = definition.disarmHandlers ?? [];
if (!armHandlers.length && !disarmHandlers.length) return;
const context = GestureRecognizer.contextOf(session, event);
// Distance decides arming unless the definition supplies .complete():
// velocity changes too quickly to drive a cue without flickering.
const armed = definition.completion
? definition.completion(context)
: tracker.progress >=
(definition.choreography.commitThreshold ?? DEFAULT_COMMIT_THRESHOLD);
if (armed === session.armed) return;
session.armed = armed;
GestureRecognizer.notify(armed ? armHandlers : disarmHandlers, context);
}
private static contextOf(
session: PointerSession,
event: PointerEvent,
): OriginGestureCompletionContext {
const { tracker } = session;
return {
origin: session.origin,
direction: session.definition.direction,
progress: tracker.progress,
velocity: tracker.velocity,
distance: tracker.distance,
crossDistance: tracker.crossDistance,
duration: Math.max(0, event.timeStamp - session.startTime),
event,
host: session.host,
bounds: session.bounds,
start: session.start,
current: pointOf(event, session.bounds),
};
}
private sessionFor(event: PointerEvent) {
const session = this.session;
return session && session.pointerId === event.pointerId ? session : null;
}
/** Directed movement passed the threshold and dominates the cross axis. */
private static recognizes(session: PointerSession, event: PointerEvent) {
const { recognition } = session.definition;
const { distance, crossDistance } = session.tracker.displacement(event);
return (
distance >= (recognition.threshold ?? DEFAULT_THRESHOLD) &&
distance >=
crossDistance * (recognition.axisDominance ?? DEFAULT_AXIS_DOMINANCE)
);
}
/**
* Resolve the definition's navigation against the origin's current context
* and pair it with its choreography. A declined navigation resolves to
* `null`; a failing one also warns.
*/
private resolveAction(
definition: OriginGestureDefinition,
): Promise<OriginAction | null> {
const fail = (error: unknown) => {
warn("A gesture's navigate() factory failed.", error);
return null;
};
try {
return Promise.resolve(
definition.navigation(this.origin.context.value),
).then(
(navigation) =>
navigation
? { ...navigation, choreography: definition.choreography }
: null,
fail,
);
} catch (error) {
return Promise.resolve(fail(error));
}
}
/** Run handlers in order; a failing handler never interrupts the others. */
private static notify(
handlers: readonly ((context: OriginGestureCompletionContext) => void)[],
context: OriginGestureCompletionContext,
) {
for (const handler of handlers) {
try {
handler(context);
} catch (error) {
queueMicrotask(() => {
throw error;
});
}
}
}
}
// Composables ---------------------------------------------------------------
/**
* Bind any number of definitions to one host. Recognizers are kept per list
* position, so re-rendering with new definition objects never interrupts a
* gesture in progress; each change applies from the next pointer-down.
*/
export const useOriginGestures = (
definitions: MaybeRefOrGetter<readonly OriginGestureDefinition[]>,
): OriginGestureBinding => {
const origin = useOrigin();
const recognizers: GestureRecognizer[] = [];
/** Grow or shrink the recognizer list to match the current definitions. */
const current = () => {
const count = toValue(definitions).length;
while (recognizers.length < count) {
const index = recognizers.length;
recognizers.push(
new GestureRecognizer(() => toValue(definitions)[index]!, origin),
);
}
for (const removed of recognizers.splice(count)) void removed.abandon();
return recognizers;
};
// The host element, for scroll-edge state and selector scrollers.
const host = shallowRef<HTMLElement | null>(null);
// Bumped by scroll events; touch-action re-reads scroll edges on change.
const scrolled = shallowRef(0);
const directional = supportsDirectionalPan();
const atEdge = (definition: Pick<OriginGestureDefinition, "start">) => {
const { start } = definition;
if (start.kind !== "scroll-edge") return false;
const element = host.value;
if (!element && typeof start.scroller === "string") return true;
return isAtScrollEdge(start.scroller, start.edge, element!);
};
// A string, so hosts re-render only when touch-action actually changes,
// never merely because something scrolled.
const touchAction = computed(() => {
void scrolled.value;
return touchActionFor(toValue(definitions), atEdge, directional);
});
const sharesScrollAxis = computed(() =>
toValue(definitions).some(sharesScroll),
);
const style = computed(() => {
const value = touchAction.value;
return {
width: "100%",
height: "100%",
// Native scrolling stays on every pan no gesture needs.
touchAction: value,
// Inherited by descendants through the package stylesheet, so nested
// scroll containers (where browsers restart touch-action) keep it too.
"--nvo-touch-action": value,
};
});
// A recognizer whose component unmounts mid-gesture cancels its operation.
onScopeDispose(() => {
for (const recognizer of recognizers.splice(0)) void recognizer.abandon();
});
// Only without directional touch-action do scroll-edge gestures need to
// claim touches from JavaScript, the one path that blocks scrolling.
const needsTouchmove = computed(() => sharesScrollAxis.value && !directional);
const onTouchmove = (event: TouchEvent) => {
if (!event.cancelable) return;
// Ask every recognizer: each one may let go of the touch as it answers.
let claimed = false;
for (const recognizer of current())
if (recognizer.claimTouch()) claimed = true;
if (claimed) event.preventDefault();
};
const onScrollCapture = () => {
scrolled.value += 1;
};
const listeners = computed(() => ({
...(sharesScrollAxis.value ? { onScrollCapture } : {}),
...(needsTouchmove.value ? { onTouchmove } : {}),
}));
return {
get style() {
return style.value;
},
get needsTouchmove() {
return needsTouchmove.value;
},
get listeners() {
return listeners.value as Record<string, (event: Event) => void>;
},
ref: (element) => {
host.value = element instanceof HTMLElement ? element : null;
scrolled.value += 1;
},
onPointerdown: (event) => {
for (const recognizer of current()) recognizer.pointerDown(event);
},
onPointermove: (event) => {
for (const recognizer of current()) void recognizer.pointerMove(event);
},
onPointerup: (event) => {
for (const recognizer of current()) void recognizer.pointerUp(event);
},
onPointercancel: () => {
for (const recognizer of current()) void recognizer.pointerCancel();
},
onLostpointercapture: (event) => {
for (const recognizer of current())
void recognizer.pointerCaptureLost(event);
},
onTouchmove,
};
};
/**
* 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.
*
* 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 a ref or getter
* returning one. A changed definition applies from the next pointer-down.
* @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: MaybeRefOrGetter<OriginGestureDefinition>,
): OriginGestureBinding {
return useOriginGestures(() => [toValue(definition)]);
}
+93
View File
@@ -0,0 +1,93 @@
/**
* 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.
*
* Modules, from the bottom up:
*
* - `shared` geometry and environment helpers
* - `choreography` visual effects, presets, and effect compositing
* - `navigation` view recipes, intents, and actions (`forward`, …)
* - `scene/graph` the scene model and its history rules
* - `scene/scene` timing, measuring, compositing, `useOrigin`
* - `gesture/definition` gesture types and the immutable builder
* - `gesture/recognizer` pointer admission, tracking, and recognition
* - `components/` Vue renderers and gesture hosts
*
* @packageDocumentation
*/
export type { OriginRect } from "./shared";
export type {
OriginChoreography,
OriginChoreographyContext,
OriginEffect,
OriginEffectSet,
OriginOperationIntent,
OriginOperationPhase,
OriginSpring,
} from "./choreography";
export {
defineOriginChoreography,
fade,
normalizedEffect,
slideLeft,
slideRight,
} from "./choreography";
export type * from "./navigation";
export {
above,
back,
forward,
originAction,
originView,
replace,
under,
} from "./navigation";
export type * from "./scene/types";
export type { OriginNodeScope, UseOrigin } from "./scene/scene";
export { OriginSceneError, type OriginSceneErrorCode } from "./scene/graph";
export {
createOriginScene,
originNodeScopeKey,
useOrigin,
} from "./scene/scene";
export type * from "./gesture/definition";
export {
allOf,
anyOf,
atScrollEdge,
gesture,
nearEdge,
startedIn,
} from "./gesture/definition";
export { useOriginGesture } from "./gesture/recognizer";
/**
* 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";
+434
View File
@@ -0,0 +1,434 @@
import { markRaw, type Component } from "vue";
import { isChoreography, type OriginChoreography } from "./choreography";
/*
* Navigation vocabulary: view recipes (what to mount) and the intents and
* actions (how history changes) that operations execute.
*/
/** Props accepted by a Vue component definition. */
export type PropsOf<T> = T extends abstract new (...args: any[]) => {
$props: infer P;
}
? P
: T extends (props: infer P, ...args: any[]) => any
? P
: never;
/**
* A view is a recipe for creating a Vue component, not a mounted instance.
*
* Forward navigation uses the recipe to create a mounted history entry. That
* instance remains mounted until a committed back operation pops it.
*
* @typeParam T - The component mounted by the recipe.
*/
export interface OriginView<T extends Component = Component> {
/** The Vue component definition that will be mounted for this recipe. */
readonly component: T;
/** Props passed to the component when the recipe is mounted. */
readonly props?: Readonly<PropsOf<T>>;
/**
* A developer-facing recipe identity used in diagnostics and as the prefix
* of generated scene-node keys. It does not make Vue reuse an instance.
*/
readonly key?: string;
/** A human-readable label used by diagnostics and DOM data attributes. */
readonly name?: string;
}
/** 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;
function componentName(component: Component): string | undefined {
return typeof component === "object" && "name" in component
? String(component.name)
: undefined;
}
/**
* 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.
*
* @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<T extends Component = Component>(
component: T,
props?: PropsOf<T>,
options: OriginViewOptions = {},
): OriginView<T> {
const name = options.name ?? componentName(component);
return markRaw({
component: markRaw(component),
props,
key: options.key ?? `${name ?? "view"}-${++viewSequence}`,
name,
});
}
/**
* Controls how an operation participates in retained instance history.
*
* - `push`: create a target whose previous entry is the mounted origin.
* - `replace`: create a target that inherits the origin's previous entry, then
* remove the origin on commit.
* - `back`: reuse the mounted previous entry and pop the origin on commit.
*/
export type OriginHistoryMode = "push" | "replace" | "back";
/**
* The target's stacking relationship to its origin while an operation exists.
*/
export type OriginPlacement = "above" | "under";
/**
* A scene mutation without visual choreography.
*
* Gesture builders keep navigation intent separate from animation so the same
* destination can be paired with different component-local interactions.
* Calling `.animate()` materializes this intent as an {@link OriginAction}.
*/
export interface OriginNavigationIntent {
/**
* Recipe for a newly created push or replacement target. Back navigation
* omits this because the scene resolves the retained previous instance.
*/
readonly target?: OriginView;
/** Target stacking relationship while the gesture operation is visible. */
readonly placement?: OriginPlacement;
/** Retained-history mutation performed if the gesture commits. */
readonly history: OriginHistoryMode;
}
/** A complete request to create and animate a target view from an origin. */
export interface OriginAction {
/**
* Recipe for a newly created push or replacement target. A back action omits
* this because the scene resolves its already-mounted previous entry.
*/
readonly target?: OriginView;
/** Visual relationship applied to the source, target, and shared frame. */
readonly choreography: OriginChoreography;
/**
* Target stacking relationship during the operation.
*
* @defaultValue `"above"`
*/
readonly placement?: OriginPlacement;
/**
* History mutation applied to the target recipe.
*
* @defaultValue `"push"`
*/
readonly history?: OriginHistoryMode;
}
/** 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;
}
/*
* Every helper accepts either `(…, options?)`, producing an animation-free
* navigation intent for gesture `.navigate()`, or `(…, choreography,
* options?)`, producing a complete action for `begin()`/`perform()`.
*/
interface HelperArguments<Options> {
readonly choreography?: OriginChoreography;
readonly options: Options;
}
function helperArguments<Options extends object>(
choreographyOrOptions: OriginChoreography | Options | undefined,
options: Options | undefined,
): HelperArguments<Partial<Options>> {
return isChoreography(choreographyOrOptions)
? { choreography: choreographyOrOptions, options: options ?? {} }
: { options: choreographyOrOptions ?? {} };
}
function navigation(
target: OriginView | undefined,
placement: OriginPlacement,
history: OriginHistoryMode,
): OriginNavigationIntent {
return { target, placement, history };
}
/** Back navigation resolves its target at execution time, so it has none. */
function targetlessNavigation(
placement: OriginPlacement,
history: OriginHistoryMode,
): OriginNavigationIntent {
return { placement, history };
}
/** Pair a navigation intent with choreography, or return it unchanged. */
function materialize(
intent: OriginNavigationIntent,
choreography: OriginChoreography | undefined,
): OriginNavigationIntent | OriginAction {
if (!choreography) return intent;
const { placement, history } = intent;
return "target" in intent
? { target: intent.target, choreography, placement, history }
: { choreography, placement, history };
}
/**
* 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 materialize(
navigation(target, options.placement ?? "above", options.history ?? "push"),
choreography,
) as OriginAction;
}
/**
* 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,
maybeOptions?: OriginNavigationActionOptions,
): OriginAction | OriginNavigationIntent {
const { choreography, options } = helperArguments(
choreographyOrOptions,
maybeOptions,
);
return materialize(
navigation(target, options.placement ?? "above", "push"),
choreography,
);
}
/**
* 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,
maybeOptions?: OriginNavigationActionOptions,
): OriginAction | OriginNavigationIntent {
const { choreography, options } = helperArguments(
choreographyOrOptions,
maybeOptions,
);
return materialize(
navigation(target, options.placement ?? "above", "replace"),
choreography,
);
}
/**
* 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,
maybeOptions?: OriginNavigationActionOptions,
): OriginAction | OriginNavigationIntent {
const { choreography, options } = helperArguments(
choreographyOrOptions,
maybeOptions,
);
return materialize(
targetlessNavigation(options.placement ?? "under", "back"),
choreography,
);
}
/**
* 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,
maybeOptions?: OriginPlacementActionOptions,
): OriginAction | OriginNavigationIntent {
const { choreography, options } = helperArguments(
choreographyOrOptions,
maybeOptions,
);
return materialize(
navigation(target, "above", options.history ?? "push"),
choreography,
);
}
/**
* 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,
maybeOptions?: OriginPlacementActionOptions,
): OriginAction | OriginNavigationIntent {
const { choreography, options } = helperArguments(
choreographyOrOptions,
maybeOptions,
);
return materialize(
navigation(target, "under", options.history ?? "push"),
choreography,
);
}
+652
View File
@@ -0,0 +1,652 @@
import { markRaw, reactive, shallowReactive, shallowRef } from "vue";
import type {
OriginAction,
OriginHistoryMode,
OriginView,
} from "../navigation";
import { clamp01, type OriginRect } from "../shared";
import type {
MutableOriginOperation,
OriginContext,
OriginFinishOptions,
OriginSceneNodeState,
} from "./types";
/** Mutable storage for one mounted scene node. */
export interface SceneNodeRecord {
key: string;
sequence: number;
view: OriginView;
/** Retained-history link: the entry a committed back returns to. */
previousNodeKey?: string;
state: OriginSceneNodeState;
/** Visual link: the operation edge positioning this node as its target. */
incomingOperationId?: number;
}
/**
* Why a scene operation was rejected.
*
* - `missing-node`: the node no longer exists.
* - `missing-origin`: the origin node does not exist.
* - `inert-origin`: the origin is parked or exposed and cannot originate.
* - `origin-busy`: the origin already has a live outgoing operation.
* - `invalid-action`: the action cannot be executed as written.
* - `no-previous-entry`: back was requested without a retained entry.
* - `undecided-forward`: back was requested through an unreleased forward.
* - `target-busy`: the retained back target is transitioning, or still
* positions the origin.
* - `outside-scene`: `useOrigin()` was called outside an `<OriginScene>` view.
*/
export type OriginSceneErrorCode =
| "missing-node"
| "missing-origin"
| "inert-origin"
| "origin-busy"
| "invalid-action"
| "no-previous-entry"
| "undecided-forward"
| "target-busy"
| "outside-scene";
/** Error thrown or rejected by scene operations, with a stable `code`. */
export class OriginSceneError extends Error {
override readonly name = "OriginSceneError";
constructor(
readonly code: OriginSceneErrorCode,
message: string,
) {
super(message);
}
}
/**
* Progress at which a release commits when the choreography does not supply
* its own `commitThreshold`.
*/
export const DEFAULT_COMMIT_THRESHOLD = 0.25;
/**
* Release velocity (progress units per second) that commits a flick at any
* progress when the choreography does not supply its own `commitVelocity`.
*/
export const DEFAULT_COMMIT_VELOCITY = 0.2;
/**
* Decide a release outcome from explicit options, or from velocity and
* progress: a flick at least `commitVelocity` fast decides by its direction,
* forward committing and backward cancelling, regardless of distance. Slower
* releases commit once progress reaches `commitThreshold`.
*/
export const shouldCommit = (
operation: MutableOriginOperation,
options: OriginFinishOptions,
): boolean => {
if (options.commit !== undefined) return options.commit;
const { commitThreshold, commitVelocity } = operation.choreography;
const flick = commitVelocity ?? DEFAULT_COMMIT_VELOCITY;
if (operation.velocity >= flick) return true;
if (operation.velocity <= -flick) return false;
return operation.progress >= (commitThreshold ?? DEFAULT_COMMIT_THRESHOLD);
};
/**
* The scene model: nodes, operation edges, and the rules that rewrite them.
*
* Two independent structures share these records:
*
* - **History** links each node to its retained previous entry through
* `previousNodeKey`. It changes only when operations commit or cancel.
* - **Visual graph** links each target to the operation that positions it
* through `incomingOperationId`; each operation links back to its origin.
* Nodes without an incoming edge are listed in `roots`.
*
* Nodes are stored flat so Vue never re-parents a component when edges are
* added, spliced, or removed. Every method is synchronous; timing (mounting,
* measuring, settling) belongs to `Scene`.
*/
export class SceneGraph {
readonly nodes = shallowReactive(new Map<string, SceneNodeRecord>());
readonly operations = shallowReactive(
new Map<number, MutableOriginOperation>(),
);
readonly roots = shallowRef<string[]>([]);
/**
* Connected resting edges temporarily removed by a back operation, keyed by
* that back operation's id. Cancelling the back operation restores the edge.
*/
private readonly suspendedEdges = new Map<number, MutableOriginOperation>();
/**
* Operations indexed by origin key, in insertion order. Every addition and
* removal goes through {@link attach} and {@link detach} to keep it in sync
* with `operations`.
*/
private readonly outgoing = shallowReactive(
new Map<string, readonly MutableOriginOperation[]>(),
);
private nodeSequence = 0;
private operationSequence = 0;
constructor(initial: readonly OriginView[]) {
this.roots.value = initial.map((view) => this.createNode(view).key);
}
// Queries -----------------------------------------------------------------
node(key: string): SceneNodeRecord | undefined {
return this.nodes.get(key);
}
operation(id: number): MutableOriginOperation | undefined {
return this.operations.get(id);
}
/** The edge positioning `node` as its target, including resting edges. */
incomingOf(node: SceneNodeRecord): MutableOriginOperation | undefined {
return node.incomingOperationId
? this.operations.get(node.incomingOperationId)
: undefined;
}
/** Incoming edges positioning `node`, from the node outward to its root. */
incomingChain(node: SceneNodeRecord): MutableOriginOperation[] {
const chain: MutableOriginOperation[] = [];
const visited = new Set<string>();
let current: SceneNodeRecord | undefined = node;
while (current && !visited.has(current.key)) {
visited.add(current.key);
const incoming = this.incomingOf(current);
if (!incoming) break;
chain.push(incoming);
current = this.nodes.get(incoming.originKey);
}
return chain;
}
/** The unfinished operation originated by a node, if any. */
liveOutgoingOf(nodeKey: string): MutableOriginOperation | undefined {
return this.outgoing
.get(nodeKey)
?.find((operation) => operation.phase !== "finished");
}
/**
* The operation whose source effects apply to a node. Unlike
* {@link liveOutgoingOf}, this includes a finished, connected resting edge.
*/
presentedOutgoingOf(nodeKey: string): MutableOriginOperation | undefined {
return this.outgoing.get(nodeKey)?.[0];
}
/** Retained entries preceding `node`, oldest first. */
historyOf(node: SceneNodeRecord): SceneNodeRecord[] {
const history: SceneNodeRecord[] = [];
const visited = new Set<string>([node.key]);
let previousKey = node.previousNodeKey;
while (previousKey && !visited.has(previousKey)) {
visited.add(previousKey);
const previous = this.nodes.get(previousKey);
if (!previous) break;
history.push(previous);
previousKey = previous.previousNodeKey;
}
return history.reverse();
}
contextFor(nodeKey: string): OriginContext {
const node = this.nodes.get(nodeKey);
if (!node)
throw new OriginSceneError(
"missing-node",
`Origin scene node "${nodeKey}" no longer exists.`,
);
const history = this.historyOf(node).map((entry) => entry.view);
return {
nodeKey,
view: node.view,
canGoBack: Boolean(node.previousNodeKey),
previous: history.at(-1),
history,
};
}
/**
* Whether a node should currently receive input.
*
* Parked and exposed entries are inert. A target being cancelled is already
* scheduled to disappear, and a source settling toward commit has ceded new
* interactions to its target. This is node-local fate, not a global "active
* view".
*/
isInteractive(nodeKey: string): boolean {
const node = this.nodes.get(nodeKey);
if (!node || node.state === "parked" || node.state === "exposed")
return false;
if (this.incomingOf(node)?.intent === "cancel") return false;
return this.liveOutgoingOf(nodeKey)?.intent !== "commit";
}
// Transitions -------------------------------------------------------------
/**
* Validate an action and add its operation edge (and, for push/replace, its
* target node). The edge starts in the `preparing` phase.
*
* @throws If the origin cannot originate, or the action cannot be resolved.
*/
begin(
originKey: string,
action: OriginAction,
originRect: OriginRect | undefined,
): MutableOriginOperation {
const origin = this.assertCanOriginate(originKey);
const history = action.history ?? "push";
if (action.choreography.persistAtRest && history !== "push")
throw new OriginSceneError(
"invalid-action",
"persistAtRest choreographies require retained-history push navigation.",
);
const { target, suspendedEdge } =
history === "back"
? this.resolveBackTarget(origin)
: { target: this.createForwardTarget(origin, action, history) };
const operation = this.createOperation({
originKey,
targetKey: target.key,
entryTargetKey: target.key,
choreography: action.choreography,
placement: action.placement ?? "above",
history,
originRect,
});
target.incomingOperationId = operation.id;
target.state = "transitioning";
origin.state = "transitioning";
if (suspendedEdge) this.suspendedEdges.set(operation.id, suspendedEdge);
return operation;
}
/** Apply interactive progress to an unfinished operation. */
update(id: number, progress: number, velocity: number): void {
const operation = this.operations.get(id);
if (!operation || operation.phase === "finished") return;
operation.progress = clamp01(progress);
operation.overshoot = progress - operation.progress;
operation.velocity = velocity;
if (operation.phase !== "preparing") operation.phase = "interactive";
}
/**
* Finalize a committed operation.
*
* A normal push parks its origin and collapses the temporary visual edge, so
* the target takes the origin's place in the visual graph. A connected
* (`persistAtRest`) push keeps the edge at progress 1 and leaves the origin
* exposed but inert. Replace and back remove the origin.
*
* @returns Whether the operation's nodes still existed to be committed.
*/
commit(id: number): boolean {
const operation = this.operations.get(id);
if (!operation) return false;
const origin = this.nodes.get(operation.originKey);
const target = this.nodes.get(operation.targetKey);
if (!origin || !target) return false;
if (operation.history === "push" && operation.choreography.persistAtRest) {
operation.progress = 1;
operation.overshoot = 0;
operation.velocity = 0;
operation.intent = "commit";
operation.phase = "finished";
origin.state = "exposed";
this.activate(target);
return true;
}
this.transferPosition(origin, target);
operation.phase = "finished";
this.detach(operation);
this.suspendedEdges.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, and any entry
// still retained above the origin now sits on the target instead.
for (const node of this.nodes.values())
if (node.previousNodeKey === origin.key && node !== target)
node.previousNodeKey = target.key;
this.removeNode(origin.key);
}
this.activate(target);
return true;
}
/**
* Finalize a cancelled operation, restoring the origin.
*
* Everything the edge created or now positions is undone. Entries from the
* origin's retained history (a back target, or a replacement of one) return
* to history; anything newer is discarded. A cancelled back that suspended
* a connected resting edge reconnects it instead.
*/
cancel(id: number): void {
const operation = this.operations.get(id);
if (!operation) return;
const origin = this.nodes.get(operation.originKey);
const entry = this.nodes.get(operation.entryTargetKey);
const current = this.nodes.get(operation.targetKey);
const suspendedEdge = this.suspendedEdges.get(id);
const retained = origin ? this.retainedKeys(origin) : new Set<string>();
operation.phase = "finished";
this.detach(operation);
this.suspendedEdges.delete(id);
if (suspendedEdge && origin && entry && current === entry) {
// Put the retained page back into its former position, then reconnect
// the resting page→drawer edge. Both nodes keep their VNodes.
this.discardOutgoing(entry.key, new Set(retained));
this.transferPosition(origin, entry);
this.attach(suspendedEdge);
origin.incomingOperationId = suspendedEdge.id;
entry.state = "exposed";
} else {
for (const node of new Set([entry, current])) {
if (!node || node === origin) continue;
if (retained.has(node.key)) this.returnToHistory(node, retained);
else this.removeBranch(node.key, new Set(retained));
}
}
if (origin) this.activate(origin);
}
// Transition steps --------------------------------------------------------
private assertCanOriginate(originKey: string): SceneNodeRecord {
const origin = this.nodes.get(originKey);
if (!origin)
throw new OriginSceneError(
"missing-origin",
`Cannot animate from missing origin "${originKey}".`,
);
if (origin.state === "parked" || origin.state === "exposed")
throw new OriginSceneError(
"inert-origin",
`Cannot animate from ${origin.state} origin "${originKey}".`,
);
if (this.liveOutgoingOf(originKey))
throw new OriginSceneError(
"origin-busy",
`Origin "${originKey}" already has an outgoing operation. ` +
"A descendant target may start its own operation instead.",
);
return origin;
}
/**
* Resolve the retained previous entry for a back operation.
*
* If the origin's incoming edge comes from that previous entry (a forward
* push still settling, or a connected resting edge), the edge is collapsed
* 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.
*/
private resolveBackTarget(origin: SceneNodeRecord): {
target: SceneNodeRecord;
suspendedEdge?: MutableOriginOperation;
} {
const previousKey = origin.previousNodeKey;
if (!previousKey)
throw new OriginSceneError(
"no-previous-entry",
`Cannot go back from origin "${origin.key}" without a previous entry.`,
);
const target = this.nodes.get(previousKey);
if (!target)
throw new OriginSceneError(
"no-previous-entry",
`Cannot go back to missing retained entry "${previousKey}".`,
);
let suspendedEdge: MutableOriginOperation | undefined;
const incoming = this.incomingOf(origin);
if (incoming?.originKey === target.key) {
if (incoming.intent !== "commit")
throw new OriginSceneError(
"undecided-forward",
"Cannot go back through an undecided forward operation.",
);
if (incoming.phase !== "finished") this.commit(incoming.id);
const resting = this.operations.get(incoming.id);
if (resting?.phase === "finished" && resting.choreography.persistAtRest) {
// Re-root A→page→drawer as A→drawer before adding drawer→page.
this.detach(resting);
this.transferPosition(target, origin);
target.incomingOperationId = undefined;
suspendedEdge = resting;
}
}
// A target still positioning the origin (through edges other than the
// one collapsed above) would close a visual cycle.
if (
this.incomingChain(origin).some(
(operation) => operation.originKey === target.key,
)
)
throw new OriginSceneError(
"target-busy",
`Retained target "${target.key}" still positions origin "${origin.key}".`,
);
if (this.liveOutgoingOf(target.key))
throw new OriginSceneError(
"target-busy",
`Retained target "${target.key}" already has an outgoing operation.`,
);
if (target.incomingOperationId)
throw new OriginSceneError(
"target-busy",
`Retained target "${target.key}" is already transitioning.`,
);
return { target, suspendedEdge };
}
/** Create the target node for a push or replace operation. */
private createForwardTarget(
origin: SceneNodeRecord,
action: OriginAction,
history: Exclude<OriginHistoryMode, "back">,
): SceneNodeRecord {
if (!action.target)
throw new OriginSceneError(
"invalid-action",
`A "${history}" operation requires a target view recipe.`,
);
// A replacement skips the origin in the retained chain. The origin stays
// mounted until commit so an interactive replacement can still cancel.
const previousKey =
history === "push" ? origin.key : origin.previousNodeKey;
return this.createNode(action.target, previousKey);
}
/**
* Make a node presentable again. A node still holding a connected resting
* edge (an open drawer, say) stays exposed rather than becoming active.
*/
private activate(node: SceneNodeRecord): void {
const resting = this.presentedOutgoingOf(node.key);
if (resting?.phase === "finished") {
node.state = "exposed";
return;
}
node.state = "active";
this.refreshPresentedState(node);
}
/**
* Recompute whether a presented node is participating in a live operation.
* Parked and exposed nodes keep their retained state.
*/
private refreshPresentedState(node: SceneNodeRecord): void {
if (node.state === "parked" || node.state === "exposed") return;
const incoming = this.incomingOf(node);
node.state =
(incoming && incoming.phase !== "finished") ||
this.liveOutgoingOf(node.key)
? "transitioning"
: "active";
}
// Structural primitives ---------------------------------------------------
private createNode(
view: OriginView,
previousNodeKey?: string,
): SceneNodeRecord {
const sequence = ++this.nodeSequence;
const node: SceneNodeRecord = shallowReactive({
key: `${view.key ?? view.name ?? "view"}::${sequence}`,
sequence,
view: markRaw(view),
previousNodeKey,
state: "active",
incomingOperationId: undefined,
});
this.nodes.set(node.key, node);
return node;
}
private createOperation(
init: Pick<
MutableOriginOperation,
| "originKey"
| "targetKey"
| "entryTargetKey"
| "choreography"
| "placement"
| "history"
| "originRect"
>,
): MutableOriginOperation {
const operation = reactive<MutableOriginOperation>({
id: ++this.operationSequence,
originKey: init.originKey,
targetKey: init.targetKey,
entryTargetKey: init.entryTargetKey,
choreography: markRaw(init.choreography),
placement: init.placement,
history: init.history,
progress: 0,
overshoot: 0,
velocity: 0,
phase: "preparing",
intent: "undecided",
originRect: init.originRect,
});
this.attach(operation);
return operation;
}
private attach(operation: MutableOriginOperation): void {
this.operations.set(operation.id, operation);
const siblings = this.outgoing.get(operation.originKey) ?? [];
this.outgoing.set(operation.originKey, [...siblings, operation]);
}
private detach(operation: MutableOriginOperation): void {
this.operations.delete(operation.id);
const siblings = (this.outgoing.get(operation.originKey) ?? []).filter(
(sibling) => sibling.id !== operation.id,
);
if (siblings.length) this.outgoing.set(operation.originKey, siblings);
else this.outgoing.delete(operation.originKey);
}
private removeNode(nodeKey: string): void {
this.nodes.delete(nodeKey);
this.roots.value = this.roots.value.filter((key) => key !== nodeKey);
}
/**
* Resolve the operations a node started, because the node is leaving
* the visible graph: nodes they created (push/replace targets, including
* replacements whose history skips this node) are removed, and retained
* entries they revealed (back targets) are restored as if that back
* operation were cancelled.
*/
/** Park a retained entry again, discarding what it started meanwhile. */
private returnToHistory(node: SceneNodeRecord, retained: Set<string>): void {
this.discardOutgoing(node.key, new Set(retained));
node.incomingOperationId = undefined;
node.state = "parked";
}
/** A node's key plus its retained history, which a cancel must keep. */
private retainedKeys(node: SceneNodeRecord): Set<string> {
return new Set([node.key, ...this.historyOf(node).map(({ key }) => key)]);
}
private discardOutgoing(nodeKey: string, visited = new Set<string>()): void {
for (const operation of [...(this.outgoing.get(nodeKey) ?? [])]) {
// Includes finished resting edges: an open drawer closes with its page.
if (operation.history === "back") {
this.cancel(operation.id);
} else {
this.detach(operation);
this.removeBranch(operation.entryTargetKey, visited);
this.removeBranch(operation.targetKey, visited);
}
}
}
/**
* Remove a node, every node whose retained history passes through it, and
* every operation touching any of them. Earlier entries are untouched.
* Operations the node itself started are discarded first.
*/
private removeBranch(nodeKey: string, visited = new Set<string>()): void {
if (visited.has(nodeKey)) return;
visited.add(nodeKey);
for (const node of [...this.nodes.values()])
if (node.previousNodeKey === nodeKey)
this.removeBranch(node.key, visited);
this.discardOutgoing(nodeKey, visited);
for (const operation of [...this.operations.values()])
if (
operation.originKey === nodeKey ||
operation.targetKey === nodeKey ||
operation.entryTargetKey === nodeKey
)
this.detach(operation);
this.removeNode(nodeKey);
}
/**
* Give `target` the visual-graph position currently held by `from`: either
* as the target of `from`'s incoming edge, or as a root. Neither Vue VNode
* moves; only edge bookkeeping changes.
*/
private transferPosition(from: SceneNodeRecord, target: SceneNodeRecord) {
const parent = this.incomingOf(from);
if (parent) {
parent.targetKey = target.key;
target.incomingOperationId = parent.id;
} else {
this.roots.value = this.roots.value.map((key) =>
key === from.key ? target.key : key,
);
target.incomingOperationId = undefined;
}
}
}
+734
View File
@@ -0,0 +1,734 @@
import {
computed,
inject,
markRaw,
nextTick,
shallowRef,
type ComputedRef,
type CSSProperties,
type InjectionKey,
} from "vue";
import {
composeEffects,
normalizedEffect,
type OriginEffect,
type OriginEffectSet,
type OriginSpring,
type OriginOperationIntent,
type OriginOperationPhase,
} from "../choreography";
import type { OriginAction, OriginView } from "../navigation";
import {
DEV,
warn,
clamp,
clamp01,
measureRect,
prefersReducedMotion,
windowRect,
type OriginRect,
} from "../shared";
import {
OriginSceneError,
SceneGraph,
shouldCommit,
type SceneNodeRecord,
} from "./graph";
import type {
CreateOriginSceneOptions,
MutableOriginOperation,
OriginContext,
OriginFinishOptions,
OriginOperation,
OriginOperationHandle,
OriginScene,
OriginSceneNode,
} from "./types";
// Compositing ---------------------------------------------------------------
/**
* Compositor invariants rather than visual theming. They stay inline so a
* missing optional stylesheet can never place scene nodes back into normal
* block/flex flow and vertically stack views.
*
* Deliberately not frozen: it is spread once per node per frame, and V8
* spreads frozen objects markedly slower.
*/
const NODE_BASE_STYLE: Readonly<CSSProperties> = {
position: "absolute",
inset: "0",
width: "100%",
height: "100%",
overflow: "hidden",
};
/** Base z-index keeping every node above content outside the scene. */
const Z_INDEX_BASE = 1_000_000;
/** z-index distance between adjacent relative layers. */
const Z_INDEX_PER_LAYER = 10_000;
/**
* Effect layers applied to a node, ordered outermost frame first.
*
* A node inherits the frame and target effects of every edge on its incoming
* chain, then adds the frame and source effects of the edge it originates.
* Ancestors' source effects are intentionally excluded: only the initiating
* component receives the source side of an operation.
*
* @param effectsOf - The current effect set of an operation's choreography.
*/
export const nodeEffectLayers = (
graph: SceneGraph,
node: SceneNodeRecord,
effectsOf: (operation: MutableOriginOperation) => OriginEffectSet,
): OriginEffect[] => {
const chain = graph.incomingChain(node);
// Evaluate choreographies node-first, then apply them outermost-first.
const effectSets = chain.map(effectsOf);
const layers: OriginEffect[] = [];
for (let index = chain.length - 1; index >= 0; index -= 1) {
const targetLayer = chain[index]!.placement === "above" ? 1 : -1;
layers.push(
normalizedEffect(effectSets[index]!.frame),
normalizedEffect(effectSets[index]!.target, targetLayer),
);
}
const outgoing = graph.presentedOutgoingOf(node.key);
if (outgoing) {
const effects = effectsOf(outgoing);
layers.push(
normalizedEffect(effects.frame),
normalizedEffect(effects.source),
);
}
return layers;
};
/** Collapse every effect layer applied to a node into its host's style. */
export const composeNodeStyle = (
graph: SceneGraph,
nodeKey: string,
effectsOf: (operation: MutableOriginOperation) => OriginEffectSet,
): CSSProperties => {
const node = graph.node(nodeKey);
if (!node) return {};
const { style, layer } = composeEffects(
nodeEffectLayers(graph, node, effectsOf),
NODE_BASE_STYLE,
);
style.zIndex = Z_INDEX_BASE + layer * Z_INDEX_PER_LAYER + node.sequence;
style.pointerEvents = graph.isInteractive(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;
};
// Settling spring -----------------------------------------------------------
/*
* A damped spring settles an operation toward 0 or 1. It keeps release
* velocity continuous with pointer motion without imposing a fixed-duration
* easing on custom choreographies.
*/
const DEFAULT_STIFFNESS = 280;
const DEFAULT_DAMPING = 30;
/** Fixed integration step, in seconds. */
const STEP = 1 / 120;
/** Frame durations are clamped to this range, in seconds. */
const MIN_FRAME = 0.001;
const MAX_FRAME = 0.032;
/** Release velocities beyond this magnitude are clamped. */
const MAX_INITIAL_VELOCITY = 8;
const POSITION_TOLERANCE = 0.002;
const VELOCITY_TOLERANCE = 0.02;
/** Position and velocity of a spring, in progress units. */
export interface SpringState {
readonly position: number;
readonly velocity: number;
}
/**
* Advance a spring by one animation frame.
*
* @param elapsedMs - Time since the previous frame in milliseconds.
*/
export const stepSpring = (
state: SpringState,
target: number,
elapsedMs: number,
spring: OriginSpring = {},
): SpringState & { readonly done: boolean } => {
const stiffness = spring.stiffness ?? DEFAULT_STIFFNESS;
const damping = spring.damping ?? DEFAULT_DAMPING;
const elapsed = clamp(elapsedMs / 1000, MIN_FRAME, MAX_FRAME);
const iterations = Math.max(1, Math.ceil(elapsed / STEP));
const dt = elapsed / iterations;
let { position, velocity } = state;
for (let index = 0; index < iterations; index += 1) {
const acceleration = (target - position) * stiffness - velocity * damping;
velocity += acceleration * dt;
position += velocity * dt;
}
const done =
Math.abs(target - position) < POSITION_TOLERANCE &&
Math.abs(velocity) < VELOCITY_TOLERANCE;
return { position, velocity, done };
};
/**
* Run a spring on `requestAnimationFrame` until it rests at `target` or
* `onFrame` returns `false` because its subject no longer exists.
*
* Each frame reports the raw position; the final frame reports exactly
* `target` with zero velocity.
*/
export const runSpring = (
from: SpringState,
target: number,
onFrame: (position: number, velocity: number) => boolean,
spring: OriginSpring = {},
): Promise<void> =>
new Promise<void>((resolve) => {
let state: SpringState = {
position: from.position,
velocity: clamp(
from.velocity,
-MAX_INITIAL_VELOCITY,
MAX_INITIAL_VELOCITY,
),
};
let previous = performance.now();
const frame = (time: number) => {
const next = stepSpring(state, target, time - previous, spring);
previous = time;
state = next;
const alive = next.done
? onFrame(target, 0)
: onFrame(next.position, next.velocity);
if (!alive || next.done) resolve();
else window.requestAnimationFrame(frame);
};
window.requestAnimationFrame(frame);
});
// Continuity check (development only) ---------------------------------------
/** A host's rendered transform as a 4×4 matrix, plus its opacity. */
interface Pose {
readonly matrix: readonly number[];
readonly opacity: number;
}
const warnedChoreographies = new WeakSet<object>();
/** Read a visible host's rendered pose; `null` if hidden or unreadable. */
const poseOf = (host: HTMLElement): Pose | null => {
const style = getComputedStyle(host);
if (style.visibility === "hidden") return null;
const matrix = parseMatrix(style.transform);
const opacity = Number(style.opacity);
return matrix && Number.isFinite(opacity) ? { matrix, opacity } : null;
};
/** Parse a computed `transform` ("none", matrix() or matrix3d()). */
const parseMatrix = (transform: string): number[] | null => {
if (!transform || transform === "none")
return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
const values = /^matrix(3d)?\(([^)]*)\)$/.exec(transform);
if (!values) return null;
const numbers = values[2]!.split(",").map(Number);
if (numbers.some((value) => !Number.isFinite(value))) return null;
if (values[1]) return numbers.length === 16 ? numbers : null;
if (numbers.length !== 6) return null;
const [a, b, c, d, e, f] = numbers as [
number,
number,
number,
number,
number,
number,
];
return [a, b, 0, 0, c, d, 0, 0, 0, 0, 1, 0, e, f, 0, 1];
};
/** Equal within half a pixel of translation and 1% of anything else. */
const posesMatch = (a: Pose, b: Pose): boolean =>
Math.abs(a.opacity - b.opacity) <= 0.01 &&
a.matrix.every((value, index) => {
const tolerance = index === 12 || index === 13 || index === 14 ? 0.5 : 0.01;
return Math.abs(value - b.matrix[index]!) <= tolerance;
});
// Scene ---------------------------------------------------------------------
/** Imperative controller for one operation, returned by `Scene.begin()`. */
class OperationHandle implements OriginOperationHandle {
readonly id: number;
readonly originKey: string;
readonly targetKey: string;
constructor(
private readonly scene: Scene,
/** Kept after the graph drops it, so `intent` stays readable. */
private readonly operation: MutableOriginOperation,
/** The target at creation; later edge rewrites do not change it. */
targetKey: string,
) {
markRaw(this);
this.id = operation.id;
this.originKey = operation.originKey;
this.targetKey = targetKey;
}
get intent(): OriginOperationIntent {
return this.operation.intent;
}
update(progress: number, velocity = 0): void {
this.scene.graph.update(this.id, progress, velocity);
}
finish(options?: OriginFinishOptions): Promise<boolean> {
return this.scene.finish(this.id, options);
}
async cancel(options?: Pick<OriginFinishOptions, "animate">): Promise<void> {
await this.scene.finish(this.id, {
commit: false,
animate: options?.animate,
});
}
}
/**
* One independent scene: the {@link SceneGraph} model plus the timing around
* it — waiting for Vue to mount targets, measuring hosts, and settling
* released operations with a spring.
*/
class Scene implements OriginScene {
/** @internal */
readonly graph: SceneGraph;
readonly nodes: ComputedRef<readonly OriginSceneNode[]>;
readonly operations: ComputedRef<readonly OriginOperation[]>;
readonly roots: SceneGraph["roots"];
/** DOM registered by the renderer, used only for measurement. */
private readonly hosts = new Map<string, HTMLElement>();
private container: HTMLElement | null = null;
private resizeObserver: ResizeObserver | null = null;
/**
* Scene-container bounds, or the browser window before mounting. Cached and
* refreshed on resize and whenever an operation begins, so compositing
* never forces layout.
*/
private readonly viewport = shallowRef<OriginRect>(windowRect());
/**
* One memoized effect set per operation. It recomputes only when that
* operation's progress, phase, intent, rects, or the viewport change, so
* nodes sharing an edge do not evaluate its choreography again.
*/
/** The latest release (finish call) of each operation. */
private readonly releases = new WeakMap<MutableOriginOperation, object>();
/** Final outcome of each operation, once a release has decided it. */
private readonly outcomes = new WeakMap<MutableOriginOperation, boolean>();
/** Interrupted releases waiting for a later release to decide. */
private readonly waiters = new WeakMap<
MutableOriginOperation,
Array<(committed: boolean) => void>
>();
private readonly effectSets = new WeakMap<
MutableOriginOperation,
ComputedRef<OriginEffectSet>
>();
constructor(options: CreateOriginSceneOptions) {
// Never proxy the scene: its methods rely on raw internal state.
markRaw(this);
this.graph = new SceneGraph(
Array.isArray(options.initial) ? options.initial : [options.initial],
);
this.roots = this.graph.roots;
this.nodes = computed(() =>
[...this.graph.nodes.values()].map((node) => ({
...node,
history: this.graph.historyOf(node).map((entry) => entry.view),
})),
);
this.operations = computed(() =>
[...this.graph.operations.values()].filter(
(operation) => operation.phase !== "finished",
),
);
}
contextFor(nodeKey: string): OriginContext {
return this.graph.contextFor(nodeKey);
}
async begin(
originKey: string,
action: OriginAction,
): Promise<OriginOperationHandle> {
if (this.container) this.refreshViewport();
// Only a scene at rest has a pose the new operation must continue from.
const poses =
DEV && this.operations.value.length === 0 ? this.capturePoses() : null;
const operation = this.graph.begin(
originKey,
action,
this.measure(originKey),
);
const { targetKey } = operation;
// Let Vue mount the target before measuring it. Gesture recognizers
// buffer pointer progress while this short preparation step is pending.
await nextTick();
operation.targetRect = this.measure(targetKey);
operation.phase = "interactive";
if (poses) this.warnOnJump(poses, operation);
return new OperationHandle(this, operation, targetKey);
}
async perform(originKey: string, action: OriginAction): Promise<boolean> {
const handle = await this.begin(originKey, action);
return handle.finish({ commit: true });
}
/**
* Fix an operation's outcome, settle it, and rewrite the graph.
*
* Calling `update()` while an operation settles "catches" it: the spring
* stops and progress is under control again. A later `finish()` then
* decides the outcome; every earlier, interrupted `finish()` resolves with
* that final outcome instead of applying its own.
*
* @internal Called through {@link OperationHandle}.
*/
finish(id: number, options: OriginFinishOptions = {}): Promise<boolean> {
const operation = this.graph.operation(id);
if (!operation) return Promise.resolve(false);
if (operation.phase === "finished")
return Promise.resolve(operation.intent === "commit");
const release = {};
this.releases.set(operation, release);
return this.release(operation, release, options);
}
private async release(
operation: MutableOriginOperation,
release: object,
options: OriginFinishOptions,
): Promise<boolean> {
// The outcome is fixed at release, so the target can originate another
// operation while this one is only visually settling.
const commit = shouldCommit(operation, options);
operation.intent = commit ? "commit" : "cancel";
operation.phase = "settling";
const current = () =>
this.releases.get(operation) === release &&
operation.phase === "settling";
await this.settle(
operation,
commit ? 1 : 0,
options.animate ?? true,
current,
);
// Another release already decided this operation.
const decided = this.outcomes.get(operation);
if (decided !== undefined) return decided;
// A later operation may already have finalized this edge.
const finished = (operation.phase as OriginOperationPhase) === "finished";
if (!this.graph.operation(operation.id) || finished)
return this.decide(
operation,
finished ? operation.intent === "commit" : commit,
);
// Caught by update() or superseded by a later finish(): wait for the
// release that decides.
if (!current())
return new Promise<boolean>((resolve) =>
this.waiters.set(operation, [
...(this.waiters.get(operation) ?? []),
resolve,
]),
);
return this.decide(
operation,
commit
? this.graph.commit(operation.id)
: (this.graph.cancel(operation.id), false),
);
}
/** Record an operation's final outcome and settle interrupted releases. */
private decide(operation: MutableOriginOperation, committed: boolean) {
this.outcomes.set(operation, committed);
for (const resolve of this.waiters.get(operation) ?? []) resolve(committed);
this.waiters.delete(operation);
return committed;
}
registerElement(nodeKey: string, element: HTMLElement | null): void {
if (element) this.hosts.set(nodeKey, element);
else this.hosts.delete(nodeKey);
}
registerContainer(element: HTMLElement | null): void {
if (element === this.container) return;
this.resizeObserver?.disconnect();
this.resizeObserver = null;
this.container = element;
if (!element) return;
this.refreshViewport();
if (typeof ResizeObserver !== "undefined") {
this.resizeObserver = new ResizeObserver(() => this.refreshViewport());
this.resizeObserver.observe(element);
}
}
styleForNode(nodeKey: string): CSSProperties {
return composeNodeStyle(this.graph, nodeKey, this.effectsOf);
}
isNodeInteractive(nodeKey: string): boolean {
return this.graph.isInteractive(nodeKey);
}
/**
* Move an operation to 0 or 1, animated unless motion is disabled. The
* spring stops early once `current()` reports the release was caught or
* superseded.
*/
private async settle(
operation: MutableOriginOperation,
target: 0 | 1,
animate: boolean,
current: () => boolean,
) {
if (!animate || prefersReducedMotion()) {
operation.progress = target;
operation.overshoot = 0;
operation.velocity = 0;
return;
}
// Start from the raw position so a rubber-banded drag springs back
// continuously instead of snapping its overshoot away.
const from = {
position: operation.progress + operation.overshoot,
velocity: operation.velocity,
};
await runSpring(
from,
target,
(position, velocity) => {
if (!this.graph.operation(operation.id) || !current()) return false;
operation.progress = clamp01(position);
operation.overshoot = position - operation.progress;
operation.velocity = velocity;
return true;
},
operation.choreography.spring,
);
}
/** Development only: each visible host's rendered transform and opacity. */
private capturePoses(): Map<string, Pose> {
const poses = new Map<string, Pose>();
for (const [key, host] of this.hosts) {
const pose = poseOf(host);
if (pose) poses.set(key, pose);
}
return poses;
}
/**
* Development only: warn when an operation's first frame moved a node that
* was visibly at rest, the one-frame "pop" of mismatched choreographies.
*/
private warnOnJump(
before: Map<string, Pose>,
operation: MutableOriginOperation,
) {
const { choreography } = operation;
if (warnedChoreographies.has(choreography)) return;
for (const [key, pose] of before) {
const host = this.hosts.get(key);
const after = host && poseOf(host);
if (!after || posesMatch(pose, after)) continue;
warnedChoreographies.add(choreography);
warn(
`"${choreography.name ?? "anonymous"}" moved node "${key}" on its ` +
"first frame. At progress 0 its effects should match the pose the " +
"scene was resting in, usually the previous choreography at " +
"progress 1; otherwise the view jumps.",
);
return;
}
}
private measure(nodeKey: string): OriginRect | undefined {
const host = this.hosts.get(nodeKey);
return host ? measureRect(host) : undefined;
}
/** Re-measure the container, replacing the cached rect only on change. */
private refreshViewport(): void {
const next = this.container ? measureRect(this.container) : windowRect();
const current = this.viewport.value;
if (
next.top !== current.top ||
next.left !== current.left ||
next.width !== current.width ||
next.height !== current.height
)
this.viewport.value = next;
}
private readonly effectsOf = (
operation: MutableOriginOperation,
): OriginEffectSet => {
let effects = this.effectSets.get(operation);
if (!effects) {
effects = computed(() =>
operation.choreography.effects({
progress: operation.progress,
overshoot: operation.overshoot,
velocity: operation.velocity,
phase: operation.phase,
intent: operation.intent,
originRect: operation.originRect,
targetRect: operation.targetRect,
viewport: this.viewport.value,
}),
);
this.effectSets.set(operation, effects);
}
return effects.value;
};
}
/**
* 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 {
return new Scene(options);
}
// Node-scoped access --------------------------------------------------------
/**
* Injection payload provided by each stable scene-node host.
*
* @internal
*/
export interface OriginNodeScope {
/** Scene containing the node. */
readonly scene: OriginScene;
/** Unique key of the mounted node. */
readonly nodeKey: string;
}
/** Node-scoped controls returned by {@link useOrigin}. */
export interface UseOrigin {
/** Key of the mounted node containing the calling component. */
readonly nodeKey: string;
/** Scene containing the calling component. */
readonly scene: OriginScene;
/** Reactive context for the calling component's scene node. */
readonly context: ComputedRef<OriginContext>;
/** Reactive shorthand for `context.value.view`. */
readonly view: ComputedRef<OriginView>;
/** Reactive shorthand for `context.value.previous`. */
readonly previous: ComputedRef<OriginView | undefined>;
/** Reactive shorthand for `context.value.canGoBack`. */
readonly canGoBack: ComputedRef<boolean>;
/** Begin an interactively controlled operation from this component. */
begin(action: OriginAction): Promise<OriginOperationHandle>;
/** Programmatically create and commit a target from this component. */
perform(action: OriginAction): Promise<boolean>;
}
/**
* 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 OriginSceneError(
"outside-scene",
"useOrigin() must be called inside an <OriginScene> view.",
);
const { scene, nodeKey } = scope;
const context = computed(() => scene.contextFor(nodeKey));
return {
nodeKey,
scene,
context,
view: computed(() => context.value.view),
previous: computed(() => context.value.previous),
canGoBack: computed(() => context.value.canGoBack),
begin: (action) => scene.begin(nodeKey, action),
perform: (action) => scene.perform(nodeKey, action),
};
}
+236
View File
@@ -0,0 +1,236 @@
import type { ComputedRef, CSSProperties, ShallowRef } from "vue";
import type {
OriginChoreography,
OriginOperationIntent,
OriginOperationPhase,
} from "../choreography";
import type {
OriginAction,
OriginHistoryMode,
OriginPlacement,
OriginView,
} from "../navigation";
import type { OriginRect } from "../shared";
/**
* Visibility/lifecycle role of a mounted scene node.
*
* - `active`: currently exposed for normal interaction.
* - `transitioning`: participating in at least one live operation edge.
* - `exposed`: retained and visually presented behind/beside the active entry,
* but inert.
* - `parked`: retained in history but visually hidden and inert.
*/
export type OriginSceneNodeState =
"active" | "transitioning" | "exposed" | "parked";
/**
* Read-only diagnostic representation of one mounted scene node.
*
* A node corresponds to one currently mounted Vue component instance.
*/
export interface OriginSceneNode {
/** Unique identity for this particular mounted scene node. */
readonly key: string;
/** Monotonically increasing creation order within the scene. */
readonly sequence: number;
/** Recipe used to create the node's component. */
readonly view: OriginView;
/** Recipes for the retained instance chain preceding this node. */
readonly history: readonly OriginView[];
/** Key of the retained mounted entry immediately before this node. */
readonly previousNodeKey?: string;
/** Current visibility/lifecycle role of this mounted instance. */
readonly state: OriginSceneNodeState;
/**
* Operation edge positioning this node as its target. This may identify a
* connected resting edge that is intentionally absent from live-operation
* diagnostics.
*/
readonly incomingOperationId?: number;
}
/** Read-only diagnostic representation of one live operation edge. */
export interface OriginOperation {
/** Unique, monotonically increasing operation identity within the scene. */
readonly id: number;
/** Key of the mounted component that originated the operation. */
readonly originKey: string;
/** Key of the newly created or retained target component. */
readonly targetKey: string;
/** Original history entry targeted before visual-edge rewrites. */
readonly entryTargetKey: string;
/** Choreography currently calculating this edge's effects. */
readonly choreography: OriginChoreography;
/** Target stacking relationship to the origin. */
readonly placement: OriginPlacement;
/** Retained-history behavior performed if this operation commits. */
readonly history: OriginHistoryMode;
/** Normalized progress in the inclusive range `0..1`. */
readonly progress: number;
/** Drag distance past either end of `progress`; usually `0`. */
readonly overshoot: number;
/** Latest normalized velocity in progress units per second. */
readonly velocity: number;
/** Current operation lifecycle phase. */
readonly phase: OriginOperationPhase;
/** Selected operation outcome. */
readonly intent: OriginOperationIntent;
}
/**
* Node-local information supplied to component-owned action factories.
*
* There is deliberately no global `current` or `active` view. The component
* handling the event is the origin represented by this context.
*/
export interface OriginContext {
/** Unique key of the mounted node that owns the interaction. */
readonly nodeKey: string;
/** Recipe used to create the origin node. */
readonly view: OriginView;
/** Whether this retained history entry has a mounted previous instance. */
readonly canGoBack: boolean;
/** Recipe belonging to the retained previous instance, when available. */
readonly previous?: OriginView;
/** Recipes belonging to all retained instances preceding this node. */
readonly history: readonly OriginView[];
}
/** Options controlling how a manually managed operation is resolved. */
export interface OriginFinishOptions {
/**
* Override the choreography's progress/velocity decision. Omit it to use the
* choreography's commit thresholds.
*/
commit?: boolean;
/**
* Whether to run the settling spring before finalizing the graph.
*
* @defaultValue `true`
*/
animate?: boolean;
}
/** Imperative controller for one mounted, live operation edge. */
export interface OriginOperationHandle {
/** Identity of the live operation controlled by this handle. */
readonly id: number;
/** Key of the source node that created the operation. */
readonly originKey: string;
/** Key of the newly mounted or retained target node. */
readonly targetKey: string;
/**
* The operation's selected outcome. It becomes `commit` or `cancel`
* synchronously when `finish()` or `cancel()` is called, before settling.
*/
readonly intent: OriginOperationIntent;
/**
* Set interactive progress and optional velocity.
*
* Progress is clamped to `0..1`; any excess is reported to choreographies as
* `overshoot`. Velocity is normalized to progress units per second and is
* used by the choreography's flick threshold.
*/
update(progress: number, velocity?: number): void;
/**
* Select commit/cancel, run the settling spring, and finalize the scene.
*
* @returns `true` when the target was retained, otherwise `false`.
*/
finish(options?: OriginFinishOptions): Promise<boolean>;
/** Cancel the operation and remove its target branch. */
cancel(options?: Pick<OriginFinishOptions, "animate">): Promise<void>;
}
/** 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[];
}
/**
* A self-contained scene graph, view-recipe history, and animation compositor.
*
* Multiple scenes may coexist and do not share nodes, history, or operations.
*/
export interface OriginScene {
/** Reactive snapshot of every currently mounted node. */
readonly nodes: ComputedRef<readonly OriginSceneNode[]>;
/** Reactive snapshot of every live animation/gesture operation. */
readonly operations: ComputedRef<readonly OriginOperation[]>;
/** Keys of visible operation-graph roots; parked history is excluded. */
readonly roots: Readonly<ShallowRef<readonly string[]>>;
/** Return the node-local action context for a mounted node key. */
contextFor(nodeKey: string): OriginContext;
/**
* Create a forward target or reveal a retained back target, then return
* manual control of its operation.
*
* @throws If the origin is missing, parked/exposed and inert, or already owns
* an outgoing operation.
*/
begin(
originKey: string,
action: OriginAction,
): Promise<OriginOperationHandle>;
/**
* Resolve a target and commit it programmatically using the settling spring.
*
* @returns `true` once the target has been committed.
*/
perform(originKey: string, action: OriginAction): Promise<boolean>;
/** @internal Register or unregister a scene node's host element. */
registerElement(nodeKey: string, element: HTMLElement | null): void;
/** @internal Register or unregister the scene container used for measurement. */
registerContainer(element: HTMLElement | null): void;
/** @internal Calculate the fully composited inline style for one node host. */
styleForNode(nodeKey: string): CSSProperties;
/** @internal Determine whether a node should currently receive pointer input. */
isNodeInteractive(nodeKey: string): boolean;
}
/**
* Mutable operation storage used by the scene implementation.
*
* @internal
*/
export interface MutableOriginOperation {
/** Unique operation identity. */
id: number;
/** Origin node key. */
originKey: string;
/** Newly created or retained target node key. */
targetKey: string;
/** Original history entry targeted before visual-edge rewrites. */
entryTargetKey: string;
/** Visual choreography for this edge. */
choreography: OriginChoreography;
/** Target stacking relationship. */
placement: OriginPlacement;
/** Retained-history behavior selected for this operation. */
history: OriginHistoryMode;
/** Normalized progress. */
progress: number;
/** Drag distance past either end of `progress`. */
overshoot: number;
/** Normalized velocity. */
velocity: number;
/** Current lifecycle phase. */
phase: OriginOperationPhase;
/** Selected outcome. */
intent: OriginOperationIntent;
/** Measured origin bounds, when an element was available. */
originRect?: OriginRect;
/** Measured target bounds, after the target mounted. */
targetRect?: OriginRect;
}
/** Public props accepted by the `OriginScene` renderer component. */
export interface OriginSceneProps {
/** Scene instance whose flat component nodes should be rendered. */
scene: OriginScene;
}
+65
View File
@@ -0,0 +1,65 @@
/** A rectangle measured in viewport CSS pixels. */
export interface OriginRect {
/** Distance from the viewport's top edge in CSS pixels. */
top: number;
/** Distance from the viewport's left edge in CSS pixels. */
left: number;
/** Rectangle width in CSS pixels. */
width: number;
/** Rectangle height in CSS pixels. */
height: number;
}
/** Clamp a value to the inclusive unit range `0..1`. */
export function clamp01(value: number): number {
return Math.max(0, Math.min(1, value));
}
/** Clamp a value to the inclusive range `min..max`. */
export function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
/** Measure an element's viewport bounds as a plain, serializable rectangle. */
export function measureRect(element: Element): OriginRect {
const { top, left, width, height } = element.getBoundingClientRect();
return { top, left, width, height };
}
/** Bounds of the browser window, or a unit rectangle outside a browser. */
export function windowRect(): OriginRect {
const browser = typeof window !== "undefined";
return {
top: 0,
left: 0,
width: browser ? window.innerWidth : 1,
height: browser ? window.innerHeight : 1,
};
}
/**
* Whether animation should resolve immediately.
*
* True outside a browser, when `requestAnimationFrame` is unavailable, or when
* the user prefers reduced motion.
*/
export function prefersReducedMotion(): boolean {
return (
typeof window === "undefined" ||
!window.requestAnimationFrame ||
Boolean(window.matchMedia?.("(prefers-reduced-motion: reduce)").matches)
);
}
/** Report a recoverable problem to developers without interrupting the UI. */
export function warn(message: string, error?: unknown): void {
if (typeof console !== "undefined")
console.warn(`[native-vue-router] ${message}`, ...(error ? [error] : []));
}
// Bundlers replace `process.env.NODE_ENV` in dependencies at build time, the
// same convention Vue and Vue Router use for their development-only checks.
declare const process: { env: { NODE_ENV?: string } };
/** Whether development-only checks and warnings are enabled. */
export const DEV: boolean = process.env.NODE_ENV !== "production";
+54
View File
@@ -0,0 +1,54 @@
.nvo-scene {
position: relative;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
isolation: isolate;
contain: layout paint;
background: #000;
}
.nvo-node {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: hidden;
will-change: transform, opacity;
backface-visibility: hidden;
}
.nvo-node > * {
width: 100%;
height: 100%;
}
.nvo-gesture {
-webkit-user-select: none;
user-select: none;
}
/*
* Browsers only combine touch-action up to the nearest scroll container, so a
* list scrolling inside a horizontal gesture host would otherwise let the
* browser take horizontal drags (and cancel the gesture). Each host publishes
* its touch-action as an inherited custom property; descendants adopt it.
* Zero specificity, so applications can still override any element.
*/
.nvo-gesture :where(*) {
touch-action: var(--nvo-touch-action);
}
/* Opted-out subtrees, such as horizontal chip rows, keep their own panning. */
.nvo-gesture :where([data-origin-gesture="ignore"]),
.nvo-gesture :where([data-origin-gesture="ignore"] *) {
touch-action: auto;
}
@media (prefers-reduced-motion: reduce) {
.nvo-node {
will-change: auto;
}
}
+263
View File
@@ -0,0 +1,263 @@
import { createApp, defineComponent, h, nextTick, type Component } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
back,
createOriginScene,
defineOriginChoreography,
forward,
gesture,
OriginGestureSurface,
OriginScene,
originView,
replace,
useOriginGesture,
type OriginGestureBinding,
type OriginGestureCompletionContext,
} from "../src";
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",
]);
});
});
+297
View File
@@ -0,0 +1,297 @@
/**
* Where an application can trigger haptic feedback with the current API.
*/
import {
createApp,
defineComponent,
h,
nextTick,
watch,
type Component,
} from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createOriginScene,
defineOriginChoreography,
forward,
gesture,
OriginScene,
originView,
useOriginGesture,
type OriginGestureDefinition,
} from "../src";
const apps: Array<ReturnType<typeof createApp>> = [];
afterEach(() => {
for (const app of apps.splice(0)) app.unmount();
document.body.innerHTML = "";
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
const motion = defineOriginChoreography({
name: "haptics-test",
effects: ({ progress }) => ({ target: { opacity: progress } }),
});
const Target: Component = defineComponent({ render: () => h("div", "T") });
function pointer(type: string, clientX: number, timeStamp: number) {
const event = new PointerEvent(type, {
bubbles: true,
cancelable: true,
button: 0,
isPrimary: true,
pointerId: 1,
clientX,
clientY: 100,
});
Object.defineProperty(event, "timeStamp", { value: timeStamp });
return event;
}
async function flush() {
for (let i = 0; i < 4; i += 1) await new Promise((r) => setTimeout(r, 0));
}
/** Mount a scene whose root installs `definition`, then return its host. */
async function mount(definition: OriginGestureDefinition) {
// Reduced motion settles instantly, so release outcomes apply at once.
vi.stubGlobal("matchMedia", () => ({ matches: true }));
const Root = defineComponent({
setup() {
const binding = useOriginGesture(definition);
return () =>
h("div", {
id: "host",
style: binding.style,
onPointerdown: binding.onPointerdown,
onPointermove: binding.onPointermove,
onPointerup: binding.onPointerup,
});
},
});
const scene = createOriginScene({ initial: originView(Root) });
const root = document.createElement("div");
document.body.append(root);
const app = createApp({ render: () => h(OriginScene, { scene }) });
apps.push(app);
app.mount(root);
await nextTick();
const host = root.querySelector("#host") as HTMLElement;
Object.defineProperty(host, "clientWidth", { value: 200 });
host.getBoundingClientRect = () =>
({ top: 0, left: 0, width: 200, height: 400 }) as DOMRect;
return { scene, host };
}
/** Swipe left by `distance` px, slowly enough that velocity cannot commit. */
async function swipe(host: HTMLElement, distance: number, log: string[]) {
host.dispatchEvent(pointer("pointerdown", 190, 0));
host.dispatchEvent(pointer("pointermove", 180, 500));
await flush();
log.push("recognized");
host.dispatchEvent(pointer("pointermove", 190 - distance, 1000));
await flush();
host.dispatchEvent(pointer("pointerup", 190 - distance, 1000));
await flush();
log.push("released");
}
describe("haptic feedback", () => {
it("can vibrate when a gesture is recognized, from .navigate()", async () => {
const log: string[] = [];
const vibrate = vi.fn(() => log.push("vibrate") > 0);
vi.stubGlobal("navigator", { ...navigator, vibrate });
const { host } = await mount(
gesture.to
.left()
.navigate(() => {
navigator.vibrate?.(10);
return forward(originView(Target));
})
.animate(motion),
);
// A short swipe that is cancelled on release still vibrated, because
// .navigate() runs at recognition, before the outcome is known.
await swipe(host, 20, log);
expect(log).toEqual(["vibrate", "recognized", "released"]);
});
it("can vibrate only when a release commits, by watching operation intent", async () => {
const log: string[] = [];
const vibrate = vi.fn(() => log.push("vibrate") > 0);
vi.stubGlobal("navigator", { ...navigator, vibrate });
const { scene, host } = await mount(
gesture.to
.left()
.navigate(() => forward(originView(Target)))
.animate(motion),
);
// Intent becomes "commit" synchronously at release, before settling.
const seen = new Set<number>();
watch(
() => scene.operations.value.filter((o) => o.intent === "commit"),
(committed) => {
for (const operation of committed)
if (!seen.has(operation.id)) {
seen.add(operation.id);
navigator.vibrate?.(10);
}
},
{ flush: "sync" },
);
await swipe(host, 20, log); // 10% progress: cancelled
expect(vibrate).not.toHaveBeenCalled();
await swipe(host, 150, log); // 75% progress: committed
expect(log).toEqual([
"recognized",
"released",
"recognized",
"vibrate",
"released",
]);
});
it("can vibrate on release from a custom .complete() decision", async () => {
const vibrate = vi.fn(() => true);
vi.stubGlobal("navigator", { ...navigator, vibrate });
const { host } = await mount(
gesture.to
.left()
.complete(({ progress }) => {
const commit = progress > 0.5;
if (commit) navigator.vibrate?.(10);
return commit;
})
.navigate(() => forward(originView(Target)))
.animate(motion),
);
await swipe(host, 20, []);
expect(vibrate).not.toHaveBeenCalled();
await swipe(host, 150, []);
expect(vibrate).toHaveBeenCalledTimes(1);
});
});
describe(".onCommit()", () => {
it("vibrates once when a release commits, and not when it cancels", async () => {
const vibrate = vi.fn((_ms: number) => true);
const { host } = await mount(
gesture.to
.left()
.navigate(() => forward(originView(Target)))
.onCommit(() => vibrate(10))
.animate(motion),
);
await swipe(host, 20, []); // 10% progress: cancelled
expect(vibrate).not.toHaveBeenCalled();
await swipe(host, 150, []); // 75% progress: committed
expect(vibrate).toHaveBeenCalledTimes(1);
expect(vibrate).toHaveBeenCalledWith(10);
});
it("is available at every stage after the direction, in order", async () => {
const calls: string[] = [];
const definition = gesture.to
.left()
.onCommit(() => calls.push("directed"))
.complete(({ progress }) => progress > 0.5)
.onCommit(() => calls.push("completed"))
.navigate(() => forward(originView(Target)))
.onCommit((context) => calls.push(`navigation ${context.direction}`))
.animate(motion);
expect(definition.commitHandlers).toHaveLength(3);
expect(Object.isFrozen(definition.commitHandlers)).toBe(true);
const { host } = await mount(definition);
await swipe(host, 150, []);
expect(calls).toEqual(["directed", "completed", "navigation left"]);
});
it("keeps earlier builder stages unchanged", () => {
const base = gesture.to.left().navigate(() => null);
const withHandler = base.onCommit(() => {});
expect(base.animate(motion).commitHandlers).toEqual([]);
expect(withHandler.animate(motion).commitHandlers).toHaveLength(1);
});
it("reports a failing handler without stopping later handlers or the commit", async () => {
const errors: unknown[] = [];
const onError = (error: unknown) => errors.push(error);
process.on("uncaughtException", onError);
try {
const after = vi.fn();
const { scene, host } = await mount(
gesture.to
.left()
.navigate(() => forward(originView(Target)))
.onCommit(() => {
throw new Error("no vibration motor");
})
.onCommit(after)
.animate(motion),
);
await swipe(host, 150, []);
expect(after).toHaveBeenCalledTimes(1);
expect(scene.nodes.value.map((node) => node.state)).toEqual([
"parked",
"active",
]);
expect(errors).toEqual([new Error("no vibration motor")]);
} finally {
process.off("uncaughtException", onError);
}
});
it("runs synchronously inside the pointer-up event", async () => {
let calledDuringDispatch = false;
let dispatching = false;
const { host } = await mount(
gesture.to
.left()
.navigate(() => forward(originView(Target)))
.onCommit(() => {
calledDuringDispatch = dispatching;
})
.animate(motion),
);
host.dispatchEvent(pointer("pointerdown", 190, 0));
host.dispatchEvent(pointer("pointermove", 180, 500));
await flush();
host.dispatchEvent(pointer("pointermove", 40, 1000));
await flush();
// No awaiting between the event and the handler: iOS only allows
// haptics, audio, and clipboard writes during the user's gesture.
dispatching = true;
host.dispatchEvent(pointer("pointerup", 40, 1000));
dispatching = false;
expect(calledDuringDispatch).toBe(true);
});
it("fires at release, before the settling animation finishes", async () => {
const frames: FrameRequestCallback[] = [];
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) =>
frames.push(cb),
);
const log: string[] = [];
const { scene, host } = await mount(
gesture.to
.left()
.navigate(() => forward(originView(Target)))
.onCommit(() => log.push("commit"))
.animate(motion),
);
vi.stubGlobal("matchMedia", () => ({ matches: false }));
await swipe(host, 150, log);
expect(log).toEqual(["recognized", "commit", "released"]);
// The spring is still waiting for frames; the operation is settling.
expect(scene.operations.value[0]?.phase).toBe("settling");
expect(frames.length).toBeGreaterThan(0);
});
});
+400
View File
@@ -0,0 +1,400 @@
import {
createApp,
defineComponent,
h,
nextTick,
shallowRef,
type Component,
} from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
allOf,
anyOf,
atScrollEdge,
createOriginScene,
defineOriginChoreography,
forward,
gesture,
nearEdge,
OriginGesture,
OriginGestureSurface,
OriginScene,
originView,
startedIn,
type OriginChoreographyContext,
type OriginGestureDefinition,
type OriginGestureStartContext,
} from "../src";
import { DirectionalTracker } from "../src/gesture/recognizer";
import { stepSpring } from "../src/scene/scene";
const apps: Array<ReturnType<typeof createApp>> = [];
afterEach(() => {
for (const app of apps.splice(0)) app.unmount();
document.body.innerHTML = "";
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
const contexts: OriginChoreographyContext[] = [];
const motion = defineOriginChoreography({
name: "v2-test",
effects: (context) => {
contexts.push(context);
return { target: { opacity: context.progress } };
},
});
const Target: Component = defineComponent({ render: () => h("div", "T") });
const toTarget = () => forward(originView(Target));
function pointer(
type: string,
clientX: number,
clientY: number,
timeStamp: number,
) {
const event = new PointerEvent(type, {
bubbles: true,
cancelable: true,
button: 0,
isPrimary: true,
pointerId: 1,
clientX,
clientY,
});
Object.defineProperty(event, "timeStamp", { value: timeStamp });
return event;
}
function touchmove() {
return new TouchEvent("touchmove", { bubbles: true, cancelable: true });
}
async function flush() {
for (let i = 0; i < 4; i += 1) await new Promise((r) => setTimeout(r, 0));
}
async function mount(root: Component) {
vi.stubGlobal("matchMedia", () => ({ matches: true }));
const scene = createOriginScene({ initial: originView(root) });
const element = document.createElement("div");
document.body.append(element);
const app = createApp({ render: () => h(OriginScene, { scene }) });
apps.push(app);
app.mount(element);
await nextTick();
const host = element.querySelector("#host") as HTMLElement;
Object.defineProperties(host, {
clientWidth: { configurable: true, value: 200 },
clientHeight: { configurable: true, value: 400 },
});
host.getBoundingClientRect = () =>
({ top: 0, left: 0, width: 200, height: 400 }) as DOMRect;
return { scene, host };
}
const hostFor = (
definition: OriginGestureDefinition,
children?: () => unknown,
) =>
defineComponent({
setup: () => () =>
h(OriginGesture, { id: "host", gesture: definition }, () =>
children ? children() : "host",
),
});
describe("recognition and native scrolling", () => {
const leftward = gesture.to.left().navigate(toTarget).animate(motion);
it("leaves scrolling to the browser through touch-action alone", async () => {
const { scene, host } = await mount(hostFor(leftward));
// A horizontal gesture keeps native vertical panning.
expect(host.style.touchAction).toBe("pan-y");
host.dispatchEvent(pointer("pointerdown", 150, 100, 0));
host.dispatchEvent(pointer("pointermove", 140, 101, 16));
await flush();
const move = touchmove();
host.dispatchEvent(move);
// Ordinary gestures never block touchmove, so the browser never waits.
expect(move.defaultPrevented).toBe(false);
expect(scene.operations.value).toHaveLength(1);
});
it("recognizes a swipe that starts with a vertical wobble", async () => {
const { scene, host } = await mount(hostFor(leftward));
host.dispatchEvent(pointer("pointerdown", 150, 100, 0));
host.dispatchEvent(pointer("pointermove", 149, 104, 16));
host.dispatchEvent(pointer("pointermove", 148, 107, 32));
host.dispatchEvent(pointer("pointermove", 120, 108, 48));
await flush();
expect(scene.operations.value).toHaveLength(1);
});
it("recognizes a swipe that starts with a wind-up the wrong way", async () => {
const { scene, host } = await mount(hostFor(leftward));
host.dispatchEvent(pointer("pointerdown", 150, 100, 0));
host.dispatchEvent(pointer("pointermove", 153, 100, 16));
host.dispatchEvent(pointer("pointermove", 156, 100, 32));
host.dispatchEvent(pointer("pointermove", 120, 100, 48));
await flush();
expect(scene.operations.value).toHaveLength(1);
});
it("claims touch-action per axis like native scrolling expects", async () => {
const down = gesture.to.down().navigate(toTarget).animate(motion);
const scroller = shallowRef<HTMLElement | null>(null);
const dismiss = gesture.from
.scrollEdge(scroller, "top")
.to.down()
.navigate(toTarget)
.animate(motion);
const surface = (gestures: OriginGestureDefinition[]) =>
defineComponent({
setup: () => () =>
h(OriginGestureSurface, { id: "host", gestures }, () => "x"),
});
expect(
(await mount(surface([leftward, down]))).host.style.touchAction,
).toBe("none");
document.body.innerHTML = "";
// A scroll-edge gesture at its edge reserves only its own pan: with the
// horizontal gesture claiming pan-x, only scrolling down remains.
expect(
(await mount(surface([leftward, dismiss]))).host.style.touchAction,
).toBe("pan-down");
});
});
describe("scroll edges", () => {
const scroller = shallowRef<HTMLElement | null>(null);
const dismiss = gesture.from
.scrollEdge(scroller, "top")
.to.down()
.navigate(toTarget)
.animate(motion);
async function mountWithScroller(scrollTop: number) {
const Root = hostFor(dismiss, () =>
h("div", {
id: "list",
ref: (element) => (scroller.value = element as HTMLElement | null),
}),
);
const mounted = await mount(Root);
let top = scrollTop;
Object.defineProperty(scroller.value!, "scrollTop", {
get: () => top,
configurable: true,
});
const scrollTo = async (value: number) => {
top = value;
scroller.value!.dispatchEvent(new Event("scroll"));
await nextTick();
};
await scrollTo(scrollTop);
return { ...mounted, scrollTo };
}
describe("with directional touch-action", () => {
it("reserves only the dismiss pan while the list is at the top", async () => {
const { host, scrollTo } = await mountWithScroller(0);
// Dragging up still scrolls natively; dragging down is the gesture's.
expect(host.style.touchAction).toBe("pan-x pan-down");
await scrollTo(120);
expect(host.style.touchAction).toBe("manipulation");
await scrollTo(0);
expect(host.style.touchAction).toBe("pan-x pan-down");
});
it("never blocks touchmove", async () => {
const { host } = await mountWithScroller(0);
host.dispatchEvent(pointer("pointerdown", 100, 100, 0));
host.dispatchEvent(pointer("pointermove", 100, 118, 16));
const move = touchmove();
host.dispatchEvent(move);
expect(move.defaultPrevented).toBe(false);
});
it("starts a downward drag only when the scroller is at the top", async () => {
const atTop = await mountWithScroller(0);
atTop.host.dispatchEvent(pointer("pointerdown", 100, 100, 0));
atTop.host.dispatchEvent(pointer("pointermove", 100, 140, 16));
await flush();
expect(atTop.scene.operations.value).toHaveLength(1);
});
it("leaves a scrolled list to scroll", async () => {
const scrolled = await mountWithScroller(120);
scrolled.host.dispatchEvent(pointer("pointerdown", 100, 100, 0));
scrolled.host.dispatchEvent(pointer("pointermove", 100, 140, 16));
await flush();
expect(scrolled.scene.operations.value).toHaveLength(0);
});
});
describe("without directional touch-action (Safari)", () => {
beforeEach(() => {
vi.stubGlobal("CSS", { supports: () => false });
});
it("keeps the axis pannable and claims touches from JavaScript", async () => {
const atTop = await mountWithScroller(0);
expect(atTop.host.style.touchAction).toBe("manipulation");
atTop.host.dispatchEvent(pointer("pointerdown", 100, 100, 0));
// The browser's first cancelable touchmove arrives after its slop.
atTop.host.dispatchEvent(pointer("pointermove", 101, 118, 16));
const first = touchmove();
atTop.host.dispatchEvent(first);
expect(first.defaultPrevented).toBe(true);
atTop.host.dispatchEvent(pointer("pointermove", 100, 140, 32));
await flush();
expect(atTop.scene.operations.value).toHaveLength(1);
});
it("lets an upward drag at the top scroll the list", async () => {
const atTop = await mountWithScroller(0);
atTop.host.dispatchEvent(pointer("pointerdown", 100, 200, 0));
atTop.host.dispatchEvent(pointer("pointermove", 100, 180, 16));
const move = touchmove();
atTop.host.dispatchEvent(move);
expect(move.defaultPrevented).toBe(false);
});
});
});
describe("start-rule helpers", () => {
const host = document.createElement("div");
host.innerHTML = `<div class="handle"><span id="inner"></span></div><p id="body"></p>`;
const context = (target: Element, localX = 100): OriginGestureStartContext =>
({
event: { target } as unknown as PointerEvent,
origin: {} as never,
host,
bounds: { top: 0, left: 0, width: 200, height: 400 },
point: { clientX: localX, clientY: 10, localX, localY: 10 },
}) as OriginGestureStartContext;
it("matches pointer-down inside a selector", () => {
expect(startedIn(".handle")(context(host.querySelector("#inner")!))).toBe(
true,
);
expect(startedIn(".handle")(context(host.querySelector("#body")!))).toBe(
false,
);
});
it("composes with anyOf and allOf", () => {
const body = host.querySelector("#body")!;
const nearLeft = nearEdge("left", 40);
expect(anyOf(startedIn(".handle"), nearLeft)(context(body, 20))).toBe(true);
expect(anyOf(startedIn(".handle"), nearLeft)(context(body, 100))).toBe(
false,
);
expect(allOf(startedIn("p"), nearLeft)(context(body, 20))).toBe(true);
expect(allOf(startedIn("p"), nearLeft)(context(body, 100))).toBe(false);
});
it("checks scroll edges, treating a missing scroller as at the edge", () => {
const list = document.createElement("div");
Object.defineProperties(list, {
scrollTop: { value: 0 },
clientHeight: { value: 100 },
scrollHeight: { value: 300 },
});
const body = host.querySelector("#body")!;
expect(atScrollEdge(list, "top")(context(body))).toBe(true);
expect(atScrollEdge(list, "bottom")(context(body))).toBe(false);
expect(atScrollEdge(null, "top")(context(body))).toBe(true);
expect(atScrollEdge(".missing", "top")(context(body))).toBe(true);
});
});
describe("progress", () => {
it("measures against a custom distance and reports overshoot", async () => {
contexts.length = 0;
const drawer = gesture.to
.right({ distance: 100 })
.navigate(toTarget)
.animate(motion);
const { scene, host } = await mount(hostFor(drawer));
host.dispatchEvent(pointer("pointerdown", 0, 100, 0));
host.dispatchEvent(pointer("pointermove", 50, 100, 16));
await flush();
host.dispatchEvent(pointer("pointermove", 130, 100, 32));
await flush();
// Progress is 130 / 100 on a 200px host: clamped to 1, 0.3 overshoot.
expect(scene.operations.value[0]).toMatchObject({ progress: 1 });
expect(scene.operations.value[0]!.overshoot).toBeCloseTo(0.3);
scene.styleForNode(scene.operations.value[0]!.targetKey);
expect(contexts.at(-1)!.overshoot).toBeCloseTo(0.3);
host.dispatchEvent(pointer("pointermove", -20, 100, 48));
await flush();
expect(scene.operations.value[0]).toMatchObject({ progress: 0 });
expect(scene.operations.value[0]!.overshoot).toBeCloseTo(-0.2);
});
it("averages velocity over recent samples instead of the last step", () => {
const tracker = new DirectionalTracker(
"right",
{ clientX: 0, clientY: 0, timeStamp: 0 },
() => 100,
);
// Steady 1px/ms with one jittery, late sample.
for (const [x, t] of [
[16, 16],
[32, 32],
[48, 48],
[52, 50],
[80, 80],
] as const)
tracker.sample({ clientX: x, clientY: 0, timeStamp: t });
expect(tracker.velocity).toBeCloseTo(10, 0); // 1px/ms over 100px.
// Held still for longer than the window: the flick is over.
tracker.sample({ clientX: 80, clientY: 0, timeStamp: 300 });
expect(tracker.velocity).toBe(0);
});
});
describe("springs", () => {
it("uses the choreography's stiffness and damping", () => {
const soft = stepSpring({ position: 0, velocity: 0 }, 1, 16, {
stiffness: 80,
});
const stiff = stepSpring({ position: 0, velocity: 0 }, 1, 16, {
stiffness: 600,
});
expect(stiff.position).toBeGreaterThan(soft.position);
const unchanged = stepSpring({ position: 0, velocity: 0 }, 1, 16);
expect(unchanged.position).toBeGreaterThan(soft.position);
});
});
describe(".onArm() and .onDisarm()", () => {
it("fire when the drag crosses the commit threshold", async () => {
const events: string[] = [];
const definition = gesture.to
.left()
.navigate(toTarget)
.onArm(({ progress }) => events.push(`arm ${progress.toFixed(2)}`))
.onDisarm(({ progress }) => events.push(`disarm ${progress.toFixed(2)}`))
.animate(motion);
const { host } = await mount(hostFor(definition));
host.dispatchEvent(pointer("pointerdown", 190, 100, 0));
for (const [x, t] of [
[180, 16],
[160, 32],
[130, 48],
[100, 64],
[150, 80],
[90, 96],
] as const) {
host.dispatchEvent(pointer("pointermove", x, 100, t));
await flush();
}
// Default arming is distance-based: 25% of the 200px host is 50px.
expect(events).toEqual(["arm 0.30", "disarm 0.20", "arm 0.50"]);
});
});
+555
View File
@@ -0,0 +1,555 @@
/**
* Property tests: random operation sequences and pointer streams must keep
* the scene's structural invariants after every step, and must never leave
* an operation stuck once all pointers are released.
*/
import { createApp, defineComponent, h, nextTick, type Component } from "vue";
import { afterEach, describe, expect, it } from "vitest";
import {
above,
back,
createOriginScene,
defineOriginChoreography,
fade,
forward,
gesture,
OriginGestureSurface,
OriginScene,
OriginSceneError,
originView,
replace,
slideLeft,
slideRight,
under,
type OriginAction,
type OriginOperationHandle,
} from "../src";
import type { SceneGraph } from "../src/scene/graph";
import type { OriginScene as Scene } from "../src/scene/types";
const SEEDS = Number(process.env.PROPERTY_SEEDS ?? 60);
// Support -------------------------------------------------------------------
function rng(seed: number) {
let a = seed >>> 0;
const next = () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
return {
next,
int: (n: number) => Math.floor(next() * n),
pick: <T>(items: readonly T[]) => items[Math.floor(next() * items.length)]!,
chance: (p: number) => next() < p,
};
}
/** Deterministic `requestAnimationFrame` and clock. */
function installFrames() {
let now = 1000;
let queue: FrameRequestCallback[] = [];
window.requestAnimationFrame = ((cb: FrameRequestCallback) =>
queue.push(cb)) as typeof window.requestAnimationFrame;
performance.now = () => now;
return {
advance(ms = 16) {
now += ms;
const run = queue;
queue = [];
for (const cb of run) cb(now);
},
drain() {
for (let i = 0; i < 1000 && queue.length; i += 1) this.advance();
},
};
}
async function flush(rounds = 4) {
for (let i = 0; i < rounds; i += 1)
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
const component = (name: string): Component =>
defineComponent({ name, render: () => h("div", name) });
/** One-line summary of a scene, for failure traces. */
const describeScene = (scene: Scene) => {
const nodes = scene.nodes.value
.map(
(n) =>
`${n.key}:${n.state}:in=${n.incomingOperationId ?? "-"}:prev=${n.previousNodeKey ?? "-"}`,
)
.join(" ");
const operations = scene.operations.value
.map(
(o) =>
`#${o.id} ${o.originKey}→${o.targetKey} ${o.history} ${o.phase}/${o.intent}`,
)
.join(", ");
return ` roots=[${scene.roots.value}] nodes=[${nodes}] ops=[${operations}]`;
};
const graphOf = (scene: Scene) =>
(scene as unknown as { graph: SceneGraph }).graph;
/** Structural invariants that must hold after every step. */
function checkInvariants(scene: Scene, label: string, rootCount: number) {
const graph = graphOf(scene);
const nodes = [...graph.nodes.values()];
const roots = scene.roots.value;
const fail = (message: string) => {
throw new Error(`${label}\n${describeScene(scene)}\n${message}`);
};
if (roots.length !== rootCount)
fail(`expected ${rootCount} roots, found ${roots.length}`);
if (new Set(roots).size !== roots.length) fail("duplicate roots");
for (const key of roots) {
const node = graph.node(key);
if (!node) fail(`root ${key} does not exist`);
else if (graph.incomingOf(node)) fail(`root ${key} has an incoming edge`);
}
for (const node of nodes) {
// Incoming edges point back at their node.
if (node.incomingOperationId !== undefined) {
const incoming = graph.operation(node.incomingOperationId);
if (!incoming) fail(`${node.key} has a dangling incoming edge`);
else if (incoming.targetKey !== node.key)
fail(`${node.key}'s incoming edge targets ${incoming.targetKey}`);
}
// History links exist and are acyclic.
const seen = new Set([node.key]);
for (let key = node.previousNodeKey; key;) {
const previous = graph.node(key);
if (!previous) fail(`${node.key} has a missing previous entry ${key}`);
if (seen.has(key)) fail(`${node.key} has cyclic history`);
seen.add(key);
key = previous?.previousNodeKey;
}
if (node.state === "parked") {
if (roots.includes(node.key)) fail(`parked ${node.key} is a root`);
if (node.incomingOperationId !== undefined)
fail(`parked ${node.key} is positioned by an edge`);
if (scene.isNodeInteractive(node.key)) fail(`parked ${node.key} is live`);
if (scene.styleForNode(node.key).visibility !== "hidden")
fail(`parked ${node.key} is visible`);
continue;
}
// Every presented node reaches a root through acyclic incoming edges.
const visited = new Set<string>();
let current = node;
for (;;) {
if (visited.has(current.key)) fail(`${node.key} is in a visual cycle`);
visited.add(current.key);
const incoming = graph.incomingOf(current);
if (!incoming) break;
const origin = graph.node(incoming.originKey);
if (!origin) fail(`${current.key}'s edge comes from a missing origin`);
current = origin!;
}
if (!roots.includes(current.key))
fail(`${node.key} is detached from every root`);
if (node.state === "exposed" && scene.isNodeInteractive(node.key))
fail(`exposed ${node.key} is live`);
}
// Every live operation joins existing nodes; one per origin.
const origins = new Set<string>();
for (const operation of scene.operations.value) {
if (!graph.node(operation.originKey))
fail(`operation ${operation.id} has a missing origin`);
if (!graph.node(operation.targetKey))
fail(`operation ${operation.id} has a missing target`);
if (origins.has(operation.originKey))
fail(`${operation.originKey} has two live operations`);
origins.add(operation.originKey);
}
}
/** Additional invariants once every operation has finished. */
function checkResting(scene: Scene, rootCount: number, label: string) {
checkInvariants(scene, label, rootCount);
const states = scene.nodes.value.map((node) => node.state);
expect(scene.operations.value, `${label}: live operations`).toEqual([]);
expect(states, `${label}: states`).not.toContain("transitioning");
expect(
states.filter((state) => state === "active"),
`${label}: one active entry per root`,
).toHaveLength(rootCount);
}
// Scene operations ----------------------------------------------------------
const drawerOpen = defineOriginChoreography({
name: "drawer-open",
persistAtRest: true,
effects: ({ progress }) => ({
source: { transform: `translateX(${progress * 66}%)` },
target: { transform: `translateX(${(progress - 1) * 66}%)` },
}),
});
const drawerClose = defineOriginChoreography({
name: "drawer-close",
commitThreshold: 0.2,
commitVelocity: 0.5,
effects: ({ progress }) => ({
source: { transform: `translateX(${-progress * 66}%)` },
}),
});
const choreographies = [slideLeft, slideRight, fade, drawerOpen, drawerClose];
const views = ["A", "B", "C", "D"].map((name) =>
originView(component(name), undefined, { key: name.toLowerCase() }),
);
function randomAction(r: ReturnType<typeof rng>): OriginAction {
const choreography = r.pick(choreographies);
const target = r.pick(views);
const placement = r.pick(["above", "under", undefined] as const);
switch (r.int(6)) {
case 0:
return forward(target, choreography, { placement });
case 1:
return replace(target, choreography, { placement });
case 2:
case 3:
return back(choreography, { placement });
case 4:
return above(target, choreography, {
history: r.pick(["push", "replace", "back"] as const),
});
default:
return under(target, choreography);
}
}
async function runOperations(seed: number) {
const r = rng(seed);
const frames = installFrames();
const initial = seed % 3 === 0 ? [views[0]!, views[1]!] : [views[0]!];
const scene = createOriginScene({ initial });
const handles: OriginOperationHandle[] = [];
const unexpected: unknown[] = [];
const expectSceneError = (error: unknown) => {
if (!(error instanceof OriginSceneError)) unexpected.push(error);
};
const trace: string[] = [];
for (let step = 0; step < 80; step += 1) {
const label = () =>
`seed ${seed} step ${step}\n${trace.slice(-60).join("\n")}`;
const roll = r.next();
const liveIds = new Set(scene.operations.value.map(({ id }) => id));
const live = handles.filter(({ id }) => liveIds.has(id));
const pick = () =>
live.length && r.chance(0.85) ? r.pick(live) : r.pick(handles);
if (roll < 0.3 || handles.length === 0) {
const originKey = r.pick(scene.nodes.value).key;
const action = randomAction(r);
scene.begin(originKey, action).then(
(handle) => {
handles.push(handle);
trace.push(
` begin #${handle.id} ${originKey}→${handle.targetKey} ${action.history} ${action.choreography.name}`,
);
},
(error) => {
trace.push(
` begin ${originKey} ${action.history} rejected: ${error.code}`,
);
expectSceneError(error);
},
);
} else if (roll < 0.55) {
const handle = pick();
const p = r.next() * 1.4 - 0.2;
const v = r.next() * 3 - 1;
trace.push(`update #${handle.id} ${p.toFixed(2)} ${v.toFixed(2)}`);
handle.update(p, v);
} else if (roll < 0.75) {
const handle = pick();
const options = {
commit: r.pick([true, false, undefined]),
animate: r.chance(0.5),
};
trace.push(`finish #${handle.id} ${JSON.stringify(options)}`);
void handle
.finish(options)
.then((v) => trace.push(` finished #${handle.id} → ${v}`));
} else if (roll < 0.82) {
const handle = pick();
trace.push(`cancel #${handle.id}`);
void handle
.cancel({ animate: r.chance(0.5) })
.then(() => trace.push(` cancelled #${handle.id}`));
} else {
trace.push("frames");
for (let i = 1 + r.int(12); i > 0; i -= 1) frames.advance(4 + r.int(40));
}
if (!r.chance(0.15)) await flush();
trace.push(describeScene(scene));
checkInvariants(scene, label(), initial.length);
}
// Release everything: every live operation finishes, then springs drain.
await flush();
for (const handle of handles) void handle.finish({ animate: r.chance(0.5) });
await flush();
frames.drain();
await flush();
checkResting(
scene,
initial.length,
`seed ${seed} at rest\n${trace.slice(-50).join("\n")}`,
);
expect(unexpected, `seed ${seed}: non-scene errors`).toEqual([]);
}
describe("scene properties", () => {
for (let seed = 1; seed <= SEEDS; seed += 1)
it(`random operations ${seed}`, () => runOperations(seed));
});
// Gestures ------------------------------------------------------------------
const apps: Array<ReturnType<typeof createApp>> = [];
afterEach(() => {
for (const app of apps.splice(0)) app.unmount();
document.body.innerHTML = "";
});
const WIDTH = 300;
const HEIGHT = 600;
function stubBounds(element: HTMLElement) {
if (element.dataset.stubbed) return;
element.dataset.stubbed = "1";
Object.defineProperties(element, {
clientWidth: { configurable: true, value: WIDTH },
clientHeight: { configurable: true, value: HEIGHT },
});
element.getBoundingClientRect = () =>
({ top: 0, left: 0, width: WIDTH, height: HEIGHT }) as DOMRect;
}
/** Views whose gestures can grow, collapse, and race chains of operations. */
function gestureWorld() {
const viewsByName: Record<string, ReturnType<typeof originView>> = {};
const slide = defineOriginChoreography({
name: "slide",
commitVelocity: 0.6,
effects: ({ progress }) => ({
source: { transform: `translateX(${-progress * 100}px)` },
target: { transform: `translateX(${(1 - progress) * 100}px)` },
}),
});
const surface = (name: string, gestures: () => unknown[]) =>
defineComponent({
name,
setup: () => () =>
h(
OriginGestureSurface,
{ gestures: gestures() as never, "data-gesture-host": "" },
() => [h("input", { "data-input": "" }), name],
),
});
const A = surface("A", () => [
gesture.to
.left()
.navigate(async () => {
await Promise.resolve();
return forward(viewsByName.B!);
})
.animate(slide),
gesture.from
.left(40)
.to.right()
.navigate((context) => (context.canGoBack ? back() : null))
.animate(slideRight),
gesture.from
.when(({ point }) => point.localY < HEIGHT / 2)
.to.down({ threshold: 12 })
.complete(({ progress }) => progress > 0.2)
.navigate(() => forward(viewsByName.C!, { placement: "under" }))
.animate(drawerOpen),
gesture.from
.bottom("20%")
.to.up()
.navigate((context) =>
context.canGoBack ? back() : replace(viewsByName.A!),
)
.animate(fade),
]);
const B = surface("B", () => [
gesture.to
.left({ threshold: 6 })
.navigate(() => forward(viewsByName.C!))
.animate(slide),
gesture.to
.right({ threshold: 6 })
.navigate((context) => (context.canGoBack ? back() : null))
.animate(slideRight),
]);
const C = surface("C", () => [
gesture.to
.up({ threshold: 4 })
.navigate((context) =>
context.canGoBack ? back() : forward(viewsByName.B!),
)
.animate(drawerClose),
gesture.to
.left()
.navigate(() => {
throw new Error("intentional navigate failure");
})
.animate(slide),
]);
viewsByName.A = originView(A, undefined, { key: "a" });
viewsByName.B = originView(B, undefined, { key: "b" });
viewsByName.C = originView(C, undefined, { key: "c" });
const scene = createOriginScene({ initial: viewsByName.A });
const root = document.createElement("div");
document.body.append(root);
const app = createApp({ render: () => h(OriginScene, { scene }) });
apps.push(app);
app.mount(root);
return { scene, root };
}
function pointerEvent(type: string, init: PointerEventInit, timeStamp: number) {
const event = new PointerEvent(type, {
bubbles: true,
cancelable: true,
...init,
});
Object.defineProperty(event, "timeStamp", { value: timeStamp });
return event;
}
async function runPointers(seed: number) {
const r = rng(seed * 7919);
const frames = installFrames();
const warn = console.warn;
console.warn = () => {};
try {
const { scene, root } = gestureWorld();
await nextTick();
let time = 100;
let x = 0;
let y = 0;
let pointerId = 1;
let host: HTMLElement | null = null;
let heading: "left" | "right" | "up" | "down" = "left";
const trace: string[] = [];
const dispatch = (
type: string,
init: PointerEventInit,
onInput = false,
) => {
trace.push(
`${type} ${host?.closest("[data-origin-node]")?.getAttribute("data-origin-node")} ${JSON.stringify(init)} t=${time}${host?.isConnected ? "" : " (detached)"}`,
);
if (!host?.isConnected) return;
const target = onInput
? (host.querySelector("[data-input]") as HTMLElement)
: host;
target.dispatchEvent(pointerEvent(type, init, time));
};
for (let step = 0; step < 120; step += 1) {
time += r.int(40);
const roll = r.next();
if (roll < 0.15) {
// A new primary pointer means the previous one ended: a browser
// delivers its pointer-up to the captured host, or, if that was
// lost, fires lostpointercapture there.
dispatch(r.chance(0.8) ? "pointerup" : "lostpointercapture", {
clientX: x,
clientY: y,
pointerId,
});
if (r.chance(0.8)) await flush(3);
const nodes = scene.nodes.value.filter((node) =>
scene.isNodeInteractive(node.key),
);
const node = r.pick(nodes.length ? nodes : scene.nodes.value);
host = root.querySelector(
`[data-origin-node="${CSS.escape(node.key)}"] [data-gesture-host]`,
);
if (host) stubBounds(host);
x = r.chance(0.3) ? r.int(50) : r.int(WIDTH);
y = r.chance(0.3) ? HEIGHT - r.int(130) : r.int(HEIGHT);
pointerId = r.chance(0.8) ? 1 : 2;
heading = r.pick(["left", "right", "up", "down"] as const);
dispatch(
"pointerdown",
{
clientX: x,
clientY: y,
pointerId,
button: r.chance(0.95) ? 0 : 1,
isPrimary: r.chance(0.95),
},
r.chance(0.08),
);
} else if (roll < 0.65) {
const amount = 5 + r.int(55);
if (heading === "left") x -= amount;
if (heading === "right") x += amount;
if (heading === "up") y -= amount;
if (heading === "down") y += amount;
dispatch("pointermove", { clientX: x, clientY: y, pointerId });
} else if (roll < 0.8) {
dispatch("pointerup", { clientX: x, clientY: y, pointerId });
} else if (roll < 0.85) {
dispatch(r.pick(["pointercancel", "lostpointercapture"]), {
pointerId,
});
} else {
trace.push("frames");
for (let i = 1 + r.int(20); i > 0; i -= 1) frames.advance(16);
}
if (r.chance(0.7)) {
frames.advance(16);
frames.advance(16);
}
if (!r.chance(0.2)) await flush(3);
trace.push(describeScene(scene));
checkInvariants(
scene,
`seed ${seed} step ${step}\n${trace.slice(-40).join("\n")}`,
1,
);
}
// Lift the pointer; nothing may remain stuck mid-transition.
dispatch("pointerup", { clientX: x, clientY: y, pointerId });
await flush();
frames.drain();
await flush();
trace.push(describeScene(scene));
checkResting(
scene,
1,
`seed ${seed} after release\n${trace.slice(-40).join("\n")}`,
);
} finally {
console.warn = warn;
}
}
describe("gesture properties", () => {
for (let seed = 1; seed <= SEEDS; seed += 1)
it(`random pointer stream ${seed}`, () => runPointers(seed));
});
+361
View File
@@ -0,0 +1,361 @@
import {
createApp,
defineComponent,
h,
nextTick,
shallowRef,
type Component,
} from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
back,
createOriginScene,
defineOriginChoreography,
forward,
gesture,
OriginGesture,
OriginGestureSurface,
OriginScene,
OriginSceneError,
originView,
type OriginGestureDefinition,
} from "../src";
import { shouldCommit } from "../src/scene/graph";
const apps: Array<ReturnType<typeof createApp>> = [];
afterEach(() => {
for (const app of apps.splice(0)) app.unmount();
document.body.innerHTML = "";
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
const motion = defineOriginChoreography({
name: "recognizer-test",
effects: ({ progress }) => ({ target: { opacity: progress } }),
});
const Target: Component = defineComponent({ render: () => h("div", "T") });
const toTarget = () => forward(originView(Target));
function pointer(
type: string,
clientX: number,
timeStamp: number,
pointerId = 1,
) {
const event = new PointerEvent(type, {
bubbles: true,
cancelable: true,
button: 0,
isPrimary: true,
pointerId,
clientX,
clientY: 100,
});
Object.defineProperty(event, "timeStamp", { value: timeStamp });
return event;
}
async function flush() {
for (let i = 0; i < 4; i += 1) await new Promise((r) => setTimeout(r, 0));
}
function stubBounds(host: HTMLElement) {
Object.defineProperty(host, "clientWidth", { value: 200 });
host.getBoundingClientRect = () =>
({ top: 0, left: 0, width: 200, height: 400 }) as DOMRect;
}
/** Render `root` as the scene's initial view; return the scene and host. */
async function mount(root: Component) {
vi.stubGlobal("matchMedia", () => ({ matches: true }));
const scene = createOriginScene({ initial: originView(root) });
const element = document.createElement("div");
document.body.append(element);
const app = createApp({ render: () => h(OriginScene, { scene }) });
apps.push(app);
app.mount(element);
await nextTick();
const host = element.querySelector("#host") as HTMLElement;
stubBounds(host);
return { scene, host, app };
}
const gestureHost = (definition: () => OriginGestureDefinition) =>
defineComponent({
setup: () => () =>
h(OriginGesture, { id: "host", gesture: definition() }, () => "host"),
});
/** Press, then drag left far enough to capture and begin an operation. */
async function dragLeft(host: HTMLElement, pointerId = 1, x = 180) {
host.dispatchEvent(pointer("pointerdown", x, 0, pointerId));
host.dispatchEvent(pointer("pointermove", x - 60, 100, pointerId));
await flush();
}
describe("stuck gestures", () => {
const definition = gesture.to.left().navigate(toTarget).animate(motion);
it("cancels a captured gesture when pointer capture is lost", async () => {
const { scene, host } = await mount(gestureHost(() => definition));
await dragLeft(host);
expect(scene.operations.value).toHaveLength(1);
host.dispatchEvent(pointer("lostpointercapture", 120, 120));
await flush();
expect(scene.operations.value).toHaveLength(0);
expect(scene.nodes.value).toHaveLength(1);
});
it("ignores a child losing implicit touch capture to the host", async () => {
const Root = defineComponent({
setup: () => () =>
h(OriginGesture, { id: "host", gesture: definition }, () =>
h("span", { id: "child" }, "x"),
),
});
const { scene, host } = await mount(Root);
await dragLeft(host);
expect(scene.operations.value).toHaveLength(1);
// Taking capture on the host makes the touched child lose its implicit
// capture; that event bubbles to the host.
host
.querySelector("#child")!
.dispatchEvent(pointer("lostpointercapture", 120, 110));
await flush();
expect(scene.operations.value).toHaveLength(1);
});
it("ignores lostpointercapture after a normal release", async () => {
const { scene, host } = await mount(gestureHost(() => definition));
await dragLeft(host);
host.dispatchEvent(pointer("pointermove", 20, 200));
host.dispatchEvent(pointer("pointerup", 20, 200));
host.dispatchEvent(pointer("lostpointercapture", 20, 200));
await flush();
// Committed, not cancelled.
expect(scene.nodes.value.map((node) => node.state)).toEqual([
"parked",
"active",
]);
});
it("cancels a stale gesture when a new primary pointer goes down", async () => {
const { scene, host } = await mount(gestureHost(() => definition));
await dragLeft(host, 1);
expect(scene.operations.value).toHaveLength(1);
// pointer 1's pointer-up was lost; a new touch starts.
host.dispatchEvent(pointer("pointerdown", 150, 300, 2));
await flush();
expect(scene.operations.value).toHaveLength(0);
expect(scene.nodes.value).toHaveLength(1);
});
it("cancels a captured gesture when its component unmounts", async () => {
const show = shallowRef(true);
const Root = defineComponent({
setup: () => () =>
h("div", [
show.value
? h(OriginGesture, { id: "host", gesture: definition }, () => "x")
: null,
]),
});
const { scene, host } = await mount(Root);
await dragLeft(host);
expect(scene.operations.value).toHaveLength(1);
show.value = false;
await flush();
expect(scene.operations.value).toHaveLength(0);
});
});
describe("failure warnings", () => {
it("warns when navigate() throws, without touching the scene", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const failing = gesture.to
.left()
.navigate(() => {
throw new Error("no route");
})
.animate(motion);
const { scene, host } = await mount(gestureHost(() => failing));
await dragLeft(host);
expect(scene.operations.value).toHaveLength(0);
expect(warn).toHaveBeenCalledWith(
"[native-vue-router] A gesture's navigate() factory failed.",
new Error("no route"),
);
});
it("warns when the scene rejects the operation", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
// The initial view has no previous entry, so back() is rejected.
const invalid = gesture.to
.left()
.navigate(() => back())
.animate(motion);
const { host } = await mount(gestureHost(() => invalid));
await dragLeft(host);
expect(warn).toHaveBeenCalledTimes(1);
const error = warn.mock.calls[0]![1] as OriginSceneError;
expect(error).toBeInstanceOf(OriginSceneError);
expect(error.code).toBe("no-previous-entry");
});
it("stays quiet when two gestures race for the same origin", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const both = [
gesture.to.left({ threshold: 4 }).navigate(toTarget).animate(motion),
gesture.to.left({ threshold: 6 }).navigate(toTarget).animate(motion),
];
const Root = defineComponent({
setup: () => () =>
h(OriginGestureSurface, { id: "host", gestures: both }, () => "x"),
});
const { scene, host } = await mount(Root);
await dragLeft(host);
expect(scene.operations.value).toHaveLength(1);
expect(warn).not.toHaveBeenCalled();
});
it("declining with null does not warn", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const declined = gesture.to
.left()
.navigate(() => null)
.animate(motion);
const { host } = await mount(gestureHost(() => declined));
await dragLeft(host);
expect(warn).not.toHaveBeenCalled();
});
});
describe("reactive gesture props", () => {
it("applies a new definition from the next pointer-down only", async () => {
const leftward = gesture.to.left().navigate(toTarget).animate(motion);
const rightward = gesture.to.right().navigate(toTarget).animate(motion);
const current = shallowRef(leftward);
const { scene, host } = await mount(gestureHost(() => current.value));
expect(host.style.touchAction).toBe("pan-y");
// Swap mid-gesture: the drag in progress keeps the leftward definition.
host.dispatchEvent(pointer("pointerdown", 180, 0));
current.value = rightward;
await nextTick();
host.dispatchEvent(pointer("pointermove", 140, 100));
await flush();
expect(scene.operations.value).toHaveLength(1);
// A short, held release (20%) cancels, leaving the origin interactive.
host.dispatchEvent(pointer("pointerup", 140, 1000));
await flush();
// The next touch uses the rightward definition.
host.dispatchEvent(pointer("pointerdown", 20, 2000));
host.dispatchEvent(pointer("pointermove", 80, 2100));
await flush();
expect(scene.operations.value).toHaveLength(1);
});
it("keeps a gesture in progress across a parent re-render", async () => {
const tick = shallowRef(0);
// A new but equivalent definition object on every render.
const { scene, host } = await mount(
gestureHost(() => {
void tick.value;
return gesture.to.left().navigate(toTarget).animate(motion);
}),
);
await dragLeft(host);
tick.value += 1;
await nextTick();
host.dispatchEvent(pointer("pointermove", 20, 200));
host.dispatchEvent(pointer("pointerup", 20, 200));
await flush();
expect(scene.nodes.value.map((node) => node.state)).toEqual([
"parked",
"active",
]);
});
it("tracks a surface's changing gesture list and touch-action", async () => {
const list = shallowRef<OriginGestureDefinition[]>([
gesture.to.left().navigate(toTarget).animate(motion),
]);
const Root = defineComponent({
setup: () => () =>
h(
OriginGestureSurface,
{ id: "host", gestures: list.value },
() => "x",
),
});
const { scene, host } = await mount(Root);
expect(host.style.touchAction).toBe("pan-y");
list.value = [
...list.value,
gesture.to.down().navigate(toTarget).animate(motion),
];
await nextTick();
expect(host.style.touchAction).toBe("none");
host.dispatchEvent(
Object.assign(pointer("pointerdown", 100, 0), { clientY: 0 }),
);
const down = new PointerEvent("pointermove", {
bubbles: true,
isPrimary: true,
pointerId: 1,
clientX: 100,
clientY: 80,
});
host.dispatchEvent(down);
await flush();
expect(scene.operations.value).toHaveLength(1);
});
});
describe("commitVelocity", () => {
const operation = (commitVelocity: number | undefined, velocity: number) =>
({
progress: 0.1,
velocity,
choreography: { effects: () => ({}), commitVelocity },
}) as unknown as Parameters<typeof shouldCommit>[0];
it("defaults to 0.2", () => {
expect(shouldCommit(operation(undefined, 0.19), {})).toBe(false);
expect(shouldCommit(operation(undefined, 0.2), {})).toBe(true);
});
it("uses the choreography's value when supplied", () => {
expect(shouldCommit(operation(0.6, 0.5), {})).toBe(false);
expect(shouldCommit(operation(0.6, 0.6), {})).toBe(true);
expect(shouldCommit(operation(0.1, 0.15), {})).toBe(true);
});
it("lets a fast flick's direction veto the distance", () => {
const at = (progress: number, velocity: number) =>
({
progress,
velocity,
choreography: { effects: () => ({}) },
}) as unknown as Parameters<typeof shouldCommit>[0];
// Past the 0.36 threshold, but flicked back toward the start: cancels.
expect(shouldCommit(at(0.8, -0.5), {})).toBe(false);
// Barely started, but flicked forward: commits.
expect(shouldCommit(at(0.05, 0.5), {})).toBe(true);
// Slow releases fall back to distance, in either direction.
expect(shouldCommit(at(0.8, -0.1), {})).toBe(true);
expect(shouldCommit(at(0.2, 0.1), {})).toBe(false);
});
it("still yields to an explicit decision", () => {
expect(shouldCommit(operation(0.1, 5), { commit: false })).toBe(false);
});
});
+550
View File
@@ -0,0 +1,550 @@
import {
createApp,
defineComponent,
h,
nextTick,
onMounted,
onUnmounted,
type Component,
} from "vue";
import { afterEach, describe, expect, it } from "vitest";
import {
above,
back,
createOriginScene,
defineOriginChoreography,
forward,
OriginScene,
originView,
replace,
under,
} from "../src";
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",
props: {
message: {
type: String,
required: true,
},
},
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"],
]);
});
});
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.app.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": true,
"noEmit": false,
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "../../node_modules/.tmp/core-v2.tsbuildinfo"
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}
+2
View File
@@ -0,0 +1,2 @@
.baseline/
browser/app/dist/
+41
View File
@@ -0,0 +1,41 @@
# Benchmarks
Compares core-v2's performance against an earlier implementation (by default
`main`).
From the repository root:
```sh
packages/core-v2/verify/setup-baseline.sh main # extract into verify/.baseline/
ONLY=baseline npx vitest bench --run --config packages/core-v2/verify/vitest.config.ts
ONLY=rewrite npx vitest bench --run --config packages/core-v2/verify/vitest.config.ts
```
`scene.bench.ts` measures compositing, push/back cycles, and rendered frames.
Run each implementation in its own process with `ONLY=`; running both in one
process biases whichever runs second.
Behavioural verification lives with the unit tests: `tests/property.test.ts`
drives random operation sequences and pointer streams and checks the scene's
structural invariants after every step. Set `PROPERTY_SEEDS` to run more.
## Real-browser feel tests
`browser/` drives a development build in Chromium with emulated touch input,
which happy-dom cannot model: implicit touch pointer capture, native
scrolling, `pointercancel`, frame timing, and rendered styles. It checks:
- **recognition** on human-like traces: wobbly and diagonal starts, a small
wind-up the wrong way, a twitch at lift-off, slow and short drags, flicks,
and vertical scrolls that must not trigger gestures;
- **frame pacing** while a finger drags (p95 ≤ 20ms, ≤ 5% of frames over
20ms) for a horizontal swipe and a scroll-edge dismiss;
- **behaviour**: swipes over nested scrollers, native scrolling beside
gestures, scroll-edge dismissal, `onCommit` inside `pointerup`, and the
development continuity warning.
```sh
npm run test:feel
```
Set `CHROMIUM_PATH` to use a Chromium other than Playwright's own.
@@ -0,0 +1,34 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<style>
html,
body,
#app {
margin: 0;
height: 100%;
overflow: hidden;
}
.page {
height: 100%;
display: flex;
flex-direction: column;
background: #fff;
}
.list {
flex: 1;
overflow: auto;
}
.row {
height: 80px;
border-bottom: 1px solid #ddd;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.ts"></script>
</body>
</html>
+141
View File
@@ -0,0 +1,141 @@
import { createApp, defineComponent, h, ref } from "vue";
import "../../../src/style.css";
import {
OriginGesture,
OriginGestureSurface,
OriginScene,
back,
createOriginScene,
defineOriginChoreography,
forward,
gesture,
originView,
slideLeft,
useOrigin,
} from "../../../src";
declare global {
interface Window {
log: string[];
inPointerup: boolean;
scene: ReturnType<typeof createOriginScene>;
scrollTopOf: (selector: string) => number;
begin: (name: "matched" | "mismatched") => Promise<void>;
}
}
window.log = [];
window.inPointerup = false;
window.addEventListener("pointerup", () => (window.inPointerup = true), true);
window.addEventListener("pointerup", () =>
setTimeout(() => (window.inPointerup = false)),
);
const warn = console.warn;
console.warn = (...args: unknown[]) => {
window.log.push("warn: " + String(args[0]));
warn(...args);
};
const rows = (n: number) =>
Array.from({ length: n }, (_, i) => h("div", { class: "row" }, `row ${i}`));
const Detail = defineComponent({
name: "Detail",
setup() {
const list = ref<HTMLElement | null>(null);
const dismiss = gesture.from
.scrollEdge(list, "top")
.to.down()
.navigate((c) => (c.canGoBack ? back() : null))
.onCommit(() => window.log.push("dismiss committed"))
.animate(
defineOriginChoreography({
name: "dismiss",
effects: ({ progress }) => ({
source: { transform: `translateY(${progress * 100}%)` },
}),
}),
);
return () =>
h(OriginGesture, { gesture: dismiss, class: "page", id: "detail" }, () =>
h("div", { class: "list", id: "detail-list", ref: list }, rows(40)),
);
},
});
const Home = defineComponent({
name: "Home",
setup() {
const open = gesture.to
.left()
.navigate(() => forward(originView(Detail, undefined, { key: "detail" })))
.onCommit(() =>
window.log.push(`open committed inPointerup=${window.inPointerup}`),
)
.animate(slideLeft);
return () =>
h(
OriginGestureSurface,
{ gestures: [open], class: "page", id: "home" },
() => h("div", { class: "list", id: "home-list" }, rows(40)),
);
},
});
// Continuity check pair: a drawer that rests at 80% and a close that starts
// either from the same pose (matched) or from 0 (mismatched).
const drawerOpen = defineOriginChoreography({
name: "drawer-open",
persistAtRest: true,
effects: ({ progress }) => ({
source: { transform: `translateX(${progress * 80}%)` },
}),
});
const closes = {
matched: defineOriginChoreography({
name: "drawer-close-matched",
effects: ({ progress }) => ({
target: { transform: `translateX(${(1 - progress) * 80}%)` },
}),
}),
mismatched: defineOriginChoreography({
name: "drawer-close-mismatched",
effects: ({ progress }) => ({
target: { transform: `translateX(${(1 - progress) * 50}%)` },
}),
}),
};
const Drawer = defineComponent({
name: "Drawer",
render: () => h("div", { class: "page" }, "drawer"),
});
const Plain = defineComponent({
name: "Plain",
setup() {
const origin = useOrigin();
(window as unknown as { openDrawer: () => Promise<boolean> }).openDrawer =
() =>
origin.perform(
forward(originView(Drawer, undefined, { key: "drawer" }), drawerOpen),
);
return () => h("div", { class: "page", id: "plain" }, "plain");
},
});
const params = new URLSearchParams(location.search);
const initial = params.get("view") === "plain" ? Plain : Home;
const scene = createOriginScene({
initial: originView(initial, undefined, { key: "root" }),
});
window.scene = scene;
window.scrollTopOf = (selector) =>
document.querySelector(selector)?.scrollTop ?? -1;
window.begin = async (name) => {
await (
window as unknown as { openDrawer: () => Promise<boolean> }
).openDrawer();
const drawerKey = scene.nodes.value.find((n) => n.view.key === "drawer")!.key;
const handle = await scene.begin(drawerKey, back(closes[name]));
await handle.cancel({ animate: false });
await handle.finish({ commit: false, animate: false });
};
createApp({ render: () => h(OriginScene, { scene }) }).mount("#app");
@@ -0,0 +1,8 @@
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite";
export default defineConfig({
root: import.meta.dirname,
base: "./",
plugins: [vue()],
build: { outDir: "dist", emptyOutDir: true, minify: false },
});
+256
View File
@@ -0,0 +1,256 @@
import { chromium } from "@playwright/test";
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
import { extname, join } from "node:path";
const root = join(import.meta.dirname, "app/dist");
const types = {
".html": "text/html",
".js": "text/javascript",
".css": "text/css",
};
const server = createServer(async (req, res) => {
const path = join(
root,
new URL(req.url, "http://x").pathname.replace(/\/$/, "/index.html"),
);
let body;
try {
body = await readFile(path);
} catch {
res.writeHead(404);
return res.end();
}
res.writeHead(200, {
"content-type": types[extname(path)] ?? "application/octet-stream",
});
res.end(body);
}).listen(0);
const base = `http://127.0.0.1:${server.address().port}/`;
// CHROMIUM_PATH overrides Playwright's bundled Chromium.
const browser = await chromium.launch({
executablePath: process.env.CHROMIUM_PATH,
});
const context = await browser.newContext({
hasTouch: true,
isMobile: true,
viewport: { width: 390, height: 844 },
});
const results = [];
const check = (name, ok, detail) => {
results.push({ name, ok, detail });
console.log(`${ok ? "PASS" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
};
async function open(query = "") {
const page = await context.newPage();
page.on("pageerror", (e) => console.log("pageerror", e.message));
await page.goto(base + query);
await page.waitForFunction(() => window.scene);
return { page, cdp: await context.newCDPSession(page) };
}
async function drag({ page, cdp }, from, to, steps = 12, stepMs = 16) {
await cdp.send("Input.dispatchTouchEvent", {
type: "touchStart",
touchPoints: [{ x: from[0], y: from[1] }],
});
for (let i = 1; i <= steps; i++) {
const x = from[0] + ((to[0] - from[0]) * i) / steps;
const y = from[1] + ((to[1] - from[1]) * i) / steps;
await cdp.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x, y }],
});
await page.waitForTimeout(stepMs);
}
await cdp.send("Input.dispatchTouchEvent", {
type: "touchEnd",
touchPoints: [],
});
await page.waitForTimeout(700);
}
const states = (page) =>
page.evaluate(() =>
window.scene.nodes.value.map((n) => `${n.view.key}:${n.state}`).join(" "),
);
const log = (page) => page.evaluate(() => window.log.slice());
// 1. Horizontal swipe over a vertical scroller commits (implicit capture + no pointercancel).
{
const t = await open();
await drag(t, [340, 400], [60, 405]);
const s = await states(t.page);
check(
"horizontal swipe over a scroller opens Detail",
s === "root:parked detail:active",
s,
);
const l = await log(t.page);
check(
"onCommit runs inside the real pointerup event",
l.includes("open committed inPointerup=true"),
l.join(" | "),
);
await t.page.close();
}
// 2. Vertical drag on the same host scrolls natively and leaves the gesture alone.
{
const t = await open();
await drag(t, [200, 700], [205, 300]);
const s = await states(t.page);
const top = await t.page.evaluate(() => window.scrollTopOf("#home-list"));
check(
"vertical drag scrolls the list natively",
top > 100,
`scrollTop=${top}`,
);
check(
"vertical drag does not start the horizontal gesture",
s === "root:active",
s,
);
await t.page.close();
}
// 3. Scroll-edge dismiss.
{
const t = await open();
await drag(t, [340, 400], [60, 405]);
check(
"(setup) Detail open",
(await states(t.page)) === "root:parked detail:active",
);
await drag(t, [200, 700], [200, 300]); // scroll the list down
const scrolled = await t.page.evaluate(() =>
window.scrollTopOf("#detail-list"),
);
check(
"drag up scrolls the detail list",
scrolled > 100,
`scrollTop=${scrolled}`,
);
await drag(t, [200, 300], [200, 600]); // scrolled: drag down scrolls back, no dismiss
const afterDown = await t.page.evaluate(() =>
window.scrollTopOf("#detail-list"),
);
check(
"drag down while scrolled scrolls the list, not dismiss",
afterDown < scrolled &&
(await states(t.page)) === "root:parked detail:active",
`scrollTop ${scrolled} → ${afterDown}`,
);
await t.page.evaluate(
() => (document.querySelector("#detail-list").scrollTop = 0),
);
await drag(t, [200, 300], [200, 800]); // at top: drag down dismisses
const s = await states(t.page);
const l = await log(t.page);
check(
"drag down at the top dismisses",
s === "root:active" && l.includes("dismiss committed"),
s,
);
await t.page.close();
}
// 3b. At the top, an upward drag still scrolls the list.
{
const t = await open();
await drag(t, [340, 400], [60, 405]);
await drag(t, [200, 700], [200, 300]);
const top = await t.page.evaluate(() => window.scrollTopOf("#detail-list"));
check(
"at the top, drag up still scrolls (dismiss lets go)",
top > 100 && (await states(t.page)) === "root:parked detail:active",
`scrollTop=${top}`,
);
await t.page.close();
}
// 5. Recognition feel: human-like traces on the Home swipe (over a nested
// scroller). Real fingers rarely start cleanly; every trace that should open
// must open, and the rest must not.
{
const steady = (total, steps, ms = 16) => Array.from({ length: steps }, () => [total / steps, 0, ms]);
const traces = [
["clean swipe", steady(-200, 12), true],
["short steady drag (31%)", steady(-120, 10), true],
["wobbly start, then swipe", [[-1, 4, 16], [-1, 3, 16], [-1, 2, 16], ...steady(-200, 12)], true],
["wind-up 6px the wrong way", [[3, 0, 16], [3, 0, 16], ...steady(-206, 12)], true],
["diagonal first 10px", [[-5, -5, 16], [-5, -4, 16], ...steady(-190, 11)], true],
["backward twitch at lift-off", [...steady(-200, 12), [6, 0, 12]], true],
["slow drag to 50%, hold, release", [...steady(-195, 40), [0, 0, 120]], true],
["slow drag to 20%, hold, release", [...steady(-80, 20), [0, 0, 120]], false],
["quick flick (15%)", steady(-60, 4, 10), true],
["vertical scroll", Array.from({ length: 12 }, () => [1, -25, 16]), false],
];
for (const [name, steps, shouldOpen] of traces) {
const t = await open();
let [x, y] = [330, 400];
await t.cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
for (const [dx, dy, ms] of steps) {
x += dx; y += dy;
if (dx || dy) await t.cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x, y }] });
await t.page.waitForTimeout(ms);
}
await t.cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
await t.page.waitForTimeout(800);
const opened = (await states(t.page)) === "root:parked detail:active";
check(`recognition: ${name} ${shouldOpen ? "opens" : "stays"}`, opened === shouldOpen, opened ? "opened" : "stayed");
await t.page.close();
}
}
// 6. Frame pacing while a finger drags: no dropped frames from blocking
// touch handling. Regressions measured p95 ≈ 28ms and ~30% of frames > 20ms.
async function pacing(query, setup, from, to) {
const gaps = [];
for (let run = 0; run < 6; run++) {
const t = await open(query);
await setup(t);
await t.page.evaluate(() => { window.f = []; const loop = () => { window.f.push(performance.now()); window.r = requestAnimationFrame(loop); }; window.r = requestAnimationFrame(loop); });
await t.cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x: from[0], y: from[1] }] });
for (let i = 1; i <= 40; i++) {
await t.cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x: from[0] + ((to[0] - from[0]) * i) / 40, y: from[1] + ((to[1] - from[1]) * i) / 40 }] });
await t.page.waitForTimeout(16);
}
const f = await t.page.evaluate(() => { cancelAnimationFrame(window.r); return window.f; });
await t.cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
gaps.push(...f.slice(1).map((v, i) => v - f[i]));
await t.page.close();
}
gaps.sort((a, b) => a - b);
return { p95: gaps[Math.floor(0.95 * (gaps.length - 1))], over20: gaps.filter((g) => g > 20).length / gaps.length };
}
const openDetail = async (t) => { await drag(t, [340, 400], [60, 405]); };
for (const [name, result] of [
["horizontal swipe drag", await pacing("", async () => {}, [340, 300], [40, 300])],
["scroll-edge dismiss drag", await pacing("", openDetail, [200, 200], [200, 600])],
]) {
check(
`pacing: ${name} p95 ≤ 20ms and ≤ 5% of frames over 20ms`,
result.p95 <= 20 && result.over20 <= 0.05,
`p95=${result.p95.toFixed(1)}ms over20=${(result.over20 * 100).toFixed(1)}%`,
);
}
// 4. Continuity warning.
for (const [name, expectWarning] of [
["matched", false],
["mismatched", true],
]) {
const t = await open("?view=plain");
await t.page.evaluate((n) => window.begin(n), name);
await t.page.waitForTimeout(100);
const warned = (await log(t.page)).some((l) => l.includes("moved node"));
check(
`continuity warning for a ${name} open/close pair: ${expectWarning ? "warns" : "silent"}`,
warned === expectWarning,
(await log(t.page)).join(" | "),
);
await t.page.close();
}
await browser.close();
server.close();
const failed = results.filter((r) => !r.ok).length;
console.log(`\n${results.length - failed}/${results.length} passed`);
process.exit(failed ? 1 : 0);
+5
View File
@@ -0,0 +1,5 @@
import { defineComponent, h, type Component } from "vue";
export function component(name: string): Component {
return defineComponent({ name, render: () => h("div", name) });
}
+101
View File
@@ -0,0 +1,101 @@
import { createApp, h, nextTick } from "vue";
import { bench, describe } from "vitest";
import * as next from "../src";
import * as base from "./.baseline";
import { component } from "./harness";
type Impl = typeof next;
const all = { baseline: base as unknown as Impl, rewrite: next };
const impls = process.env.ONLY
? { [process.env.ONLY]: all[process.env.ONLY as keyof typeof all] }
: all;
const motion = next.defineOriginChoreography({
name: "bench",
effects: ({ progress }) => ({
frame: { transform: `translateY(${progress}px)` },
source: {
transform: `translateX(${-progress * 100}px)`,
opacity: 1 - progress,
},
target: {
transform: `translateX(${(1 - progress) * 100}px)`,
style: { filter: "none" },
},
}),
});
/** Long enough for both implementations to reach steady-state JIT. */
const options = { time: 4000, warmupTime: 1500, warmupIterations: 2000 };
const views = Array.from({ length: 12 }, (_, i) =>
next.originView(component(`V${i}`), undefined, { key: `v${i}` }),
);
/** A scene with `depth` simultaneously live, chained operations. */
async function liveChain(M: Impl, depth: number) {
const scene = M.createOriginScene({ initial: views[0]! });
const handles: next.OriginOperationHandle[] = [];
let origin = scene.nodes.value[0]!.key;
for (let i = 1; i <= depth; i += 1) {
const handle = await scene.begin(origin, M.forward(views[i]!, motion));
handle.update(0.5);
handles.push(handle);
origin = handle.targetKey;
}
return { scene, handles };
}
for (const depth of [3, 10]) {
describe(`styleForNode, every node, ${depth} live chained operations`, async () => {
for (const [name, M] of Object.entries(impls)) {
const { scene, handles } = await liveChain(M, depth);
let p = 0;
bench(
name,
() => {
p = (p + 0.013) % 1;
handles[0]!.update(p);
for (const node of scene.nodes.value) scene.styleForNode(node.key);
},
options,
);
}
});
}
describe("push → back cycle (begin + finish, no animation)", () => {
for (const [name, M] of Object.entries(impls)) {
const scene = M.createOriginScene({ initial: views[0]! });
const root = scene.nodes.value[0]!.key;
bench(
name,
async () => {
const push = await scene.begin(root, M.forward(views[1]!, motion));
await push.finish({ commit: true, animate: false });
const pop = await scene.begin(push.targetKey, M.back(motion));
await pop.finish({ commit: true, animate: false });
},
options,
);
}
});
describe("rendered frame: update progress + Vue re-render, 6 live operations", async () => {
for (const [name, M] of Object.entries(impls)) {
const { scene, handles } = await liveChain(M, 6);
const host = document.createElement("div");
document.body.append(host);
createApp({ render: () => h(M.OriginScene, { scene }) }).mount(host);
await nextTick();
let p = 0;
bench(
name,
async () => {
p = (p + 0.013) % 1;
for (const handle of handles) handle.update(p);
await nextTick();
},
options,
);
}
});
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env sh
# Extract core-v2 sources from a git ref into ./.baseline for comparison.
set -eu
ref="${1:-main}"
here="$(cd "$(dirname "$0")" && pwd)"
root="$(git -C "$here" rev-parse --show-toplevel)"
tmp="$(mktemp -d)"
git -C "$root" archive "$ref" packages/core-v2/src | tar -x -C "$tmp"
rm -rf "$here/.baseline"
mv "$tmp/packages/core-v2/src" "$here/.baseline"
rm -f "$here/.baseline"/*.test.ts
rm -rf "$tmp"
echo "Baseline extracted from $ref into $here/.baseline"
+16
View File
@@ -0,0 +1,16 @@
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vitest/config";
/*
* Benchmarks against a previous core-v2 implementation.
* Run `setup-baseline.sh <git-ref>` first; see README.md.
*/
export default defineConfig({
plugins: [vue() as never],
test: {
root: __dirname,
environment: "happy-dom",
testTimeout: 120_000,
benchmark: { include: ["*.bench.ts"] },
},
});
+20
View File
@@ -0,0 +1,20 @@
import { resolve } from "node:path";
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [vue()],
build: {
outDir: "dist",
emptyOutDir: true,
lib: {
entry: resolve(__dirname, "src/index.ts"),
formats: ["es"],
fileName: "index",
cssFileName: "style",
},
// V2 intentionally has no vue-router dependency. Vue is supplied by the
// consuming application so every scene shares the application's renderer.
rollupOptions: { external: ["vue"] },
},
});
-760
View File
@@ -1,760 +0,0 @@
import {
computed,
defineComponent,
h,
inject,
onBeforeUnmount,
onMounted,
onScopeDispose,
provide,
ref,
shallowReactive,
watch,
type PropType,
type InjectionKey,
type VNode,
} from "vue";
import {
routeLocationKey,
RouterView,
type RouteLocationNormalizedLoaded,
type RouteLocationRaw,
} from "vue-router";
import { nativeRouterKey } from "./runtime";
import type {
NativeDirection,
NativeNavigationOptions,
NativeRouterRuntime,
NativeSourceRect,
NativeViewEntry,
NativeViewLifecycle,
NativeViewRole,
} from "./types";
const nativeViewLifecycleKey: InjectionKey<NativeViewLifecycle> = Symbol(
"native-view-lifecycle",
);
export function useNativeRouter() {
const runtime = inject<NativeRouterRuntime>(nativeRouterKey);
if (!runtime)
throw new Error(
"Native Vue Router is not installed. Call app.use(nativeRouter).",
);
return runtime;
}
export function useNativeViewLifecycle() {
const lifecycle = inject<NativeViewLifecycle>(nativeViewLifecycleKey);
if (!lifecycle)
throw new Error(
"Native view lifecycle APIs must be used inside NativeRouterView.",
);
return lifecycle;
}
type NativeViewHook = () => void;
function onNativeViewState(
source: Readonly<{ value: boolean }>,
entering: boolean,
hook: NativeViewHook,
) {
onMounted(() => {
if (entering && source.value) hook();
});
watch(
() => source.value,
(value, previous) => {
if (value === entering && previous !== entering) hook();
},
{ flush: "sync" },
);
}
export function onNativeViewActivate(hook: NativeViewHook) {
onNativeViewState(useNativeViewLifecycle().isActive, true, hook);
}
export function onNativeViewDeactivate(hook: NativeViewHook) {
onNativeViewState(useNativeViewLifecycle().isActive, false, hook);
}
export function onNativeViewShow(hook: NativeViewHook) {
onNativeViewState(useNativeViewLifecycle().isVisible, true, hook);
}
export function onNativeViewHide(hook: NativeViewHook) {
onNativeViewState(useNativeViewLifecycle().isVisible, false, hook);
}
export function onNativeViewEvict(
hook: (reason: NativeViewLifecycle["evictionReason"]["value"]) => void,
) {
const lifecycle = useNativeViewLifecycle();
onBeforeUnmount(() => {
if (lifecycle.status.value === "evicted")
hook(lifecycle.evictionReason.value);
});
}
function useNativeViewEffect(
source: Readonly<{ value: boolean }>,
effect: () => void | (() => void),
) {
let cleanup: void | (() => void);
const stopEffect = () => {
cleanup?.();
cleanup = undefined;
};
const stopWatch = watch(
() => source.value,
(enabled) => {
stopEffect();
if (enabled) cleanup = effect();
},
{ immediate: true, flush: "sync" },
);
onScopeDispose(() => {
stopWatch();
stopEffect();
});
}
/** Runs an effect only while this route is the semantically active route. */
export function useNativeViewActiveEffect(effect: () => void | (() => void)) {
useNativeViewEffect(useNativeViewLifecycle().isActive, effect);
}
/** Runs an effect while this route is active or participating in a transition. */
export function useNativeViewVisibleEffect(effect: () => void | (() => void)) {
useNativeViewEffect(useNativeViewLifecycle().isVisible, effect);
}
const NativeRouteScope = defineComponent({
name: "NativeRouteScope",
props: {
route: {
type: Object as PropType<RouteLocationNormalizedLoaded>,
required: true,
},
entryKey: { type: String, required: true },
},
setup(props, { slots }) {
const runtime = useNativeRouter();
const scopedRoute = shallowReactive({
...props.route,
}) as RouteLocationNormalizedLoaded;
watch(
() => props.route,
(route) => Object.assign(scopedRoute, route),
{ immediate: true },
);
provide(routeLocationKey, scopedRoute);
const entry = computed(() =>
runtime.entries.value.find(
(candidate) => candidate.key === props.entryKey,
),
);
const role = computed<NativeViewRole>(() =>
entry.value ? interactiveRole(entry.value, runtime) : "inactive",
);
provide<NativeViewLifecycle>(nativeViewLifecycleKey, {
key: props.entryKey,
route: computed(() => entry.value?.route ?? props.route),
status: computed(() => entry.value?.status ?? "evicted"),
role,
isActive: computed(() => runtime.activeKey.value === props.entryKey),
isVisible: computed(() => role.value !== "inactive"),
isPreview: computed(
() => runtime.transaction.value?.toKey === props.entryKey,
),
isCached: computed(() =>
Boolean(entry.value?.mounted && role.value === "inactive"),
),
evictionReason: computed(() => entry.value?.evictionReason),
});
return () => slots.default?.();
},
});
function interactiveRole(
entry: NativeViewEntry,
runtime: NativeRouterRuntime,
): NativeViewRole {
const transaction = runtime.transaction.value;
if (!transaction)
return entry.key === runtime.activeKey.value ? "active" : "inactive";
if (entry.key === transaction.fromKey) return "from";
if (entry.key === transaction.toKey) return "to";
return "inactive";
}
export const NativeRouterView = defineComponent({
name: "NativeRouterView",
setup(_, { slots }) {
const runtime = useNativeRouter();
return () => {
const transaction = runtime.transaction.value;
const style = transaction
? { "--native-progress": String(transaction.progress) }
: undefined;
const children = runtime.entries.value
.filter((entry) => entry.mounted)
.map((entry) => {
const role = interactiveRole(entry, runtime);
const definition = transaction
? runtime.presentationFor(transaction.presentation)
: undefined;
const customStyle =
transaction && (role === "from" || role === "to")
? definition?.layerStyle?.({
progress: transaction.progress,
role,
direction: transaction.direction,
sourceRect: transaction.sourceRect,
})
: undefined;
return h(
"section",
{
key: entry.key,
class: ["nvr-view", `nvr-view--${role}`],
style: customStyle,
"data-native-role": role,
"data-native-route": entry.route.fullPath,
"data-native-presentation": transaction?.presentation,
"data-native-direction": transaction?.direction,
inert: role === "inactive" ? "" : undefined,
"aria-hidden": role === "inactive" ? "true" : undefined,
},
[
h(
RouterView,
{ route: entry.route },
{
default: ({
Component,
route,
}: {
Component: VNode | null;
route: RouteLocationNormalizedLoaded;
}) => {
if (slots.default)
return slots.default({ Component, route, entry });
return Component
? h(
NativeRouteScope,
{ route, entryKey: entry.key },
{ default: () => Component },
)
: null;
},
},
),
],
);
});
return h(
"div",
{
class: [
"nvr-router-view",
transaction && "nvr-router-view--interactive",
],
style,
"data-native-presentation": transaction?.presentation,
"data-native-direction": transaction?.direction,
"data-native-transaction": transaction?.id,
"data-native-velocity": transaction
? String(transaction.velocity)
: undefined,
},
children,
);
};
},
});
export const NativeLink = defineComponent({
name: "NativeLink",
props: {
to: {
type: [String, Object] as PropType<RouteLocationRaw>,
required: true,
},
replace: Boolean,
presentation: String,
},
setup(props, { slots, attrs }) {
const runtime = useNativeRouter();
const href = computed(() => runtime.router.resolve(props.to).href);
const activate = (event: MouseEvent) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
)
return;
event.preventDefault();
void (props.replace
? runtime.replace(props.to, { presentation: props.presentation })
: runtime.push(props.to, { presentation: props.presentation }));
};
return () =>
h(
"a",
{ ...attrs, href: href.value, onClick: activate },
slots.default?.(),
);
},
});
function shouldIgnoreGesture(target: EventTarget | null) {
if (!(target instanceof Element)) return true;
return Boolean(
target.closest(
'[data-native-gesture="ignore"], input, textarea, select, option, [contenteditable="true"]',
),
);
}
function sourceRect(element: HTMLElement): NativeSourceRect {
const rect = element.getBoundingClientRect();
return {
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
};
}
function createPointerGesture(
element: () => HTMLElement | null,
begin: (direction: NativeDirection) => Promise<number | null>,
directionForDelta: (deltaX: number) => NativeDirection | null,
) {
let pointerId = -1;
let startX = 0;
let startY = 0;
let lastX = 0;
let lastTime = 0;
let captured = false;
let beginPromise: Promise<number | null> | null = null;
let ending = false;
let bufferedProgress = 0;
let bufferedVelocity = 0;
let gestureSign = 0;
const down = (event: PointerEvent) => {
if (
!event.isPrimary ||
event.button !== 0 ||
shouldIgnoreGesture(event.target)
)
return;
pointerId = event.pointerId;
startX = lastX = event.clientX;
startY = event.clientY;
lastTime = event.timeStamp;
captured = false;
beginPromise = null;
ending = false;
bufferedProgress = 0;
bufferedVelocity = 0;
gestureSign = 0;
};
const move = async (event: PointerEvent) => {
if (event.pointerId !== pointerId) return;
const dx = event.clientX - startX;
const dy = event.clientY - startY;
if (!captured) {
if (Math.abs(dx) < 8 || Math.abs(dx) < Math.abs(dy) * 1.15) return;
const direction = directionForDelta(dx);
if (!direction) return reset();
captured = true;
ending = false;
gestureSign = Math.sign(dx);
element()?.setPointerCapture(pointerId);
beginPromise = begin(direction);
}
event.preventDefault();
const elapsed = Math.max(8, event.timeStamp - lastTime);
const width = Math.max(1, element()?.clientWidth ?? window.innerWidth);
bufferedVelocity =
((event.clientX - lastX) * gestureSign * 1000) / (elapsed * width);
bufferedProgress = Math.max(0, Math.min(1, (dx * gestureSign) / width));
lastX = event.clientX;
lastTime = event.timeStamp;
const pending = beginPromise;
if (pending) {
const id = await pending;
if (pending !== beginPromise || ending) return;
if (id === null) return reset();
const runtime = injectRuntimeFromElement(element());
runtime?.updateInteractive(bufferedProgress, bufferedVelocity);
}
};
const up = async (event: PointerEvent) => {
if (event.pointerId !== pointerId) return;
ending = true;
const runtime = injectRuntimeFromElement(element());
const pending = beginPromise;
const shouldFinish = captured;
const progress = bufferedProgress;
const velocity = bufferedVelocity;
// Detach this pointer before awaiting preload/navigation/animation work. A
// new gesture may now start without this release callback erasing it.
reset();
const id = pending ? await pending : null;
if (shouldFinish && id !== null && runtime?.transaction.value?.id === id) {
runtime.updateInteractive(progress, velocity);
await runtime.finishInteractive();
}
};
const cancel = async () => {
ending = true;
const runtime = injectRuntimeFromElement(element());
const pending = beginPromise;
const shouldCancel = captured;
reset();
const id = pending ? await pending : null;
if (shouldCancel && id !== null && runtime?.transaction.value?.id === id)
await runtime.cancelInteractive();
};
const reset = () => {
pointerId = -1;
captured = false;
beginPromise = null;
ending = false;
bufferedProgress = 0;
bufferedVelocity = 0;
gestureSign = 0;
};
return { down, move, up, cancel };
}
const runtimeByElement = new WeakMap<Element, NativeRouterRuntime>();
function injectRuntimeFromElement(element: Element | null) {
return element ? runtimeByElement.get(element) : undefined;
}
export const NativeGestureLink = defineComponent({
name: "NativeGestureLink",
inheritAttrs: false,
props: {
to: {
type: [String, Object] as PropType<RouteLocationRaw>,
required: true,
},
presentation: { type: String, default: "reveal" },
replace: Boolean,
direction: {
type: String as PropType<"left" | "right" | "any">,
default: "any",
},
as: { type: String, default: "div" },
},
setup(props, { slots, attrs }) {
const runtime = useNativeRouter();
const root = ref<HTMLElement | null>(null);
let dragDistance = 0;
let suppressClick = false;
const gesture = createPointerGesture(
() => root.value,
async (direction) =>
runtime.beginInteractive("push", props.to, {
presentation: props.presentation,
replace: props.replace,
direction,
sourceRect: root.value ? sourceRect(root.value) : undefined,
}),
(dx) => {
if (props.direction === "left" && dx >= 0) return null;
if (props.direction === "right" && dx <= 0) return null;
return dx < 0 ? "forward" : "back";
},
);
onBeforeUnmount(() => void runtime.cancelInteractive());
const click = (event: MouseEvent) => {
if (suppressClick) {
suppressClick = false;
event.preventDefault();
event.stopPropagation();
return;
}
void runtime.push(props.to, {
presentation: props.presentation,
replace: props.replace,
});
};
return () =>
h(
props.as,
{
...attrs,
ref: (value: unknown) => {
root.value = value as HTMLElement | null;
if (root.value) runtimeByElement.set(root.value, runtime);
},
class: ["nvr-gesture-link", attrs.class],
onPointerdown: (event: PointerEvent) => {
// Component-owned gestures outrank their containing navigator.
event.stopPropagation();
dragDistance = 0;
suppressClick = false;
gesture.down(event);
},
onPointermove: (event: PointerEvent) => {
if (event.buttons) {
dragDistance += Math.abs(event.movementX);
if (dragDistance > 8) suppressClick = true;
}
void gesture.move(event);
},
onPointerup: gesture.up,
onPointercancel: gesture.cancel,
onClick: click,
},
slots.default?.(),
);
},
});
export const NativeNavigator = defineComponent({
name: "NativeNavigator",
props: {
siblings: {
type: Array as PropType<RouteLocationRaw[]>,
default: () => [],
},
edgeWidth: { type: Number, default: 28 },
},
setup(props, { slots }) {
const runtime = useNativeRouter();
const root = ref<HTMLElement | null>(null);
let candidate: "back" | "sibling" | null = null;
const gesture = createPointerGesture(
() => root.value,
async (direction) => {
if (candidate === "back") return runtime.beginInteractive("pop");
const current = runtime.router.currentRoute.value.fullPath;
const index = props.siblings.findIndex(
(route) => runtime.router.resolve(route).fullPath === current,
);
const nextIndex = index + (direction === "forward" ? 1 : -1);
const target = props.siblings[nextIndex];
if (index < 0 || !target) return null;
return runtime.beginInteractive("sibling", target, {
replace:
runtime.router.resolve(target).meta.native?.siblingHistory !==
"push",
direction,
presentation: "slide",
});
},
(dx) => {
const rtl =
getComputedStyle(root.value ?? document.documentElement).direction ===
"rtl";
const logicalDx = rtl ? -dx : dx;
if (candidate === "back") return logicalDx > 0 ? "back" : null;
return logicalDx < 0 ? "forward" : "back";
},
);
const gestureSettingAllowsNavigation = () =>
runtime.router.currentRoute.value.meta.native?.gesture !== false;
const atLeadingEdge = (event: PointerEvent) => {
const rect = root.value?.getBoundingClientRect();
const rtl =
getComputedStyle(root.value ?? document.documentElement).direction ===
"rtl";
return rect
? (rtl ? rect.right - event.clientX : event.clientX - rect.left) <=
props.edgeWidth
: false;
};
const captureDown = (event: PointerEvent) => {
if (
!event.isPrimary ||
event.button !== 0 ||
shouldIgnoreGesture(event.target)
)
return;
if (
!atLeadingEdge(event) ||
!gestureSettingAllowsNavigation() ||
!runtime.canGoBack.value
)
return;
candidate = "back";
gesture.down(event);
// The application shell owns the physical back edge. Component-owned
// route gestures retain priority everywhere else.
event.stopPropagation();
};
const down = (event: PointerEvent) => {
candidate =
props.siblings.length && gestureSettingAllowsNavigation()
? "sibling"
: null;
if (candidate) gesture.down(event);
};
return () =>
h(
"div",
{
ref: (value: unknown) => {
root.value = value as HTMLElement | null;
if (root.value) runtimeByElement.set(root.value, runtime);
},
class: "nvr-navigator",
"data-native-can-go-back": String(runtime.canGoBack.value),
onPointerdownCapture: captureDown,
onPointerdown: down,
onPointermove: gesture.move,
onPointerup: gesture.up,
onPointercancel: gesture.cancel,
},
slots.default?.(),
);
},
});
export const NativeDismissGesture = defineComponent({
name: "NativeDismissGesture",
inheritAttrs: false,
props: {
as: { type: String, default: "div" },
},
setup(props, { slots, attrs }) {
const runtime = useNativeRouter();
const root = ref<HTMLElement | null>(null);
let pointerId = -1;
let startX = 0;
let startY = 0;
let lastY = 0;
let lastTime = 0;
let captured = false;
let beginPromise: Promise<number | null> | null = null;
let ending = false;
let progress = 0;
let velocity = 0;
const reset = () => {
pointerId = -1;
captured = false;
beginPromise = null;
ending = false;
progress = 0;
velocity = 0;
};
const down = (event: PointerEvent) => {
if (
!event.isPrimary ||
event.button !== 0 ||
shouldIgnoreGesture(event.target)
)
return;
event.stopPropagation();
pointerId = event.pointerId;
startX = event.clientX;
startY = lastY = event.clientY;
lastTime = event.timeStamp;
captured = false;
beginPromise = null;
ending = false;
progress = 0;
velocity = 0;
};
const move = async (event: PointerEvent) => {
if (event.pointerId !== pointerId) return;
const dx = event.clientX - startX;
const dy = event.clientY - startY;
if (!captured) {
if (dy < 8 || Math.abs(dy) < Math.abs(dx) * 1.15) return;
captured = true;
ending = false;
root.value?.setPointerCapture(pointerId);
beginPromise = runtime.beginInteractive("dismiss", undefined, {
direction: "back",
});
}
event.preventDefault();
progress = Math.max(
0,
Math.min(
1,
dy / Math.max(1, root.value?.clientHeight ?? window.innerHeight),
),
);
const height = Math.max(
1,
root.value?.clientHeight ?? window.innerHeight,
);
velocity =
((event.clientY - lastY) * 1000) /
(Math.max(8, event.timeStamp - lastTime) * height);
lastY = event.clientY;
lastTime = event.timeStamp;
const pending = beginPromise;
if (pending) {
const id = await pending;
if (pending !== beginPromise || ending) return;
if (id === null) return reset();
runtime.updateInteractive(progress, velocity);
}
};
const up = async (event: PointerEvent) => {
if (event.pointerId !== pointerId) return;
ending = true;
const pending = beginPromise;
const shouldFinish = captured;
const finalProgress = progress;
const finalVelocity = velocity;
reset();
const id = pending ? await pending : null;
if (shouldFinish && id !== null && runtime.transaction.value?.id === id) {
runtime.updateInteractive(finalProgress, finalVelocity);
await runtime.finishInteractive();
}
};
const cancel = async () => {
ending = true;
const pending = beginPromise;
const shouldCancel = captured;
reset();
const id = pending ? await pending : null;
if (shouldCancel && id !== null && runtime.transaction.value?.id === id)
await runtime.cancelInteractive();
};
onBeforeUnmount(() => void cancel());
return () =>
h(
props.as,
{
...attrs,
ref: (value: unknown) => {
root.value = value as HTMLElement | null;
},
class: ["nvr-dismiss-gesture", attrs.class],
onPointerdown: down,
onPointermove: move,
onPointerup: up,
onPointercancel: cancel,
},
slots.default?.(),
);
},
});
export function navigationOptionsFromElement(
element: HTMLElement,
): NativeNavigationOptions {
return { sourceRect: sourceRect(element) };
}

Some files were not shown because too many files have changed in this diff Show More