diff --git a/apps/origins-demo/.gitignore b/apps/origins-demo/.gitignore new file mode 100644 index 0000000..1542b5c --- /dev/null +++ b/apps/origins-demo/.gitignore @@ -0,0 +1 @@ +dev-dist \ No newline at end of file diff --git a/apps/origins-demo/index.html b/apps/origins-demo/index.html new file mode 100644 index 0000000..75591c0 --- /dev/null +++ b/apps/origins-demo/index.html @@ -0,0 +1,25 @@ + + + + + + + + + + + + + Routeless Origins Lab + + +
+ + + diff --git a/apps/origins-demo/package.json b/apps/origins-demo/package.json new file mode 100644 index 0000000..b1ce041 --- /dev/null +++ b/apps/origins-demo/package.json @@ -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" + } +} diff --git a/apps/origins-demo/src/App.vue b/apps/origins-demo/src/App.vue new file mode 100644 index 0000000..b3d643f --- /dev/null +++ b/apps/origins-demo/src/App.vue @@ -0,0 +1,78 @@ + + + diff --git a/apps/origins-demo/src/components/InstanceCard.vue b/apps/origins-demo/src/components/InstanceCard.vue new file mode 100644 index 0000000..923d23b --- /dev/null +++ b/apps/origins-demo/src/components/InstanceCard.vue @@ -0,0 +1,15 @@ + + + diff --git a/apps/origins-demo/src/gallery-data.ts b/apps/origins-demo/src/gallery-data.ts new file mode 100644 index 0000000..8ef2e19 --- /dev/null +++ b/apps/origins-demo/src/gallery-data.ts @@ -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.", + }, +]; diff --git a/apps/origins-demo/src/lab-state.ts b/apps/origins-demo/src/lab-state.ts new file mode 100644 index 0000000..019f11f --- /dev/null +++ b/apps/origins-demo/src/lab-state.ts @@ -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([]); + +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 }; +} diff --git a/apps/origins-demo/src/main.ts b/apps/origins-demo/src/main.ts new file mode 100644 index 0000000..0d3049b --- /dev/null +++ b/apps/origins-demo/src/main.ts @@ -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"); diff --git a/apps/origins-demo/src/motions.ts b/apps/origins-demo/src/motions.ts new file mode 100644 index 0000000..ae3756b --- /dev/null +++ b/apps/origins-demo/src/motions.ts @@ -0,0 +1,325 @@ +import { defineOriginChoreography } from "@native-vue-router/core-v2"; + +const percent = (value: number) => `${(value * 100).toFixed(3)}%`; + +/** A bottom-up cover similar to presenting a native media player. */ +export const coverUp = defineOriginChoreography({ + name: "cover-up", + commitThreshold: 0.3, + commitVelocity: 0.75, + effects: ({ progress }) => ({ + source: { + transform: `translate3d(0, ${percent(progress * -0.035)}, 0) scale(${1 - progress * 0.055})`, + opacity: 1 - progress * 0.22, + style: { filter: `saturate(${1 - progress * 0.3})` }, + }, + target: { + transform: `translate3d(0, ${percent(1 - progress)}, 0)`, + style: { + borderRadius: `${(1 - progress) * 30}px`, + boxShadow: `0 -24px 80px rgb(0 0 0 / ${progress * 0.38})`, + }, + }, + }), +}); + +/** Reverse of coverUp. The retained origin instance is revealed underneath. */ +export const coverDown = defineOriginChoreography({ + name: "cover-down", + commitThreshold: 0.28, + commitVelocity: 0.72, + effects: ({ progress }) => ({ + source: { + transform: `translate3d(0, ${percent(progress)}, 0)`, + style: { borderRadius: `${progress * 30}px` }, + }, + target: { + transform: `translate3d(0, ${percent(-0.035 + progress * 0.035)}, 0) scale(${0.945 + progress * 0.055})`, + opacity: 0.78 + progress * 0.22, + style: { filter: `saturate(${0.7 + progress * 0.3})` }, + }, + }), +}); + +/** + * A scale-and-focus transition. Filters use local-last CSS composition while + * geometry remains independently composable with any ancestor operation. + */ +export const focusPortal = defineOriginChoreography({ + name: "focus-portal", + commitThreshold: 0.32, + effects: ({ progress }) => ({ + source: { + transform: `scale(${1 - progress * 0.12})`, + opacity: 1 - progress * 0.55, + style: { filter: `blur(${progress * 8}px)` }, + }, + target: { + transform: `translate3d(0, ${percent((1 - progress) * 0.08)}, 0) scale(${0.72 + progress * 0.28})`, + opacity: progress, + style: { + borderRadius: `${(1 - progress) * 42}px`, + filter: `blur(${(1 - progress) * 3}px)`, + }, + }, + }), +}); + +export const unfocusPortal = defineOriginChoreography({ + name: "unfocus-portal", + commitThreshold: 0.3, + effects: ({ progress }) => ({ + source: { + transform: `translate3d(0, ${percent(progress * 0.08)}, 0) scale(${1 - progress * 0.28})`, + opacity: 1 - progress, + style: { + borderRadius: `${progress * 42}px`, + filter: `blur(${progress * 3}px)`, + }, + }, + target: { + transform: `scale(${0.88 + progress * 0.12})`, + opacity: 0.45 + progress * 0.55, + style: { filter: `blur(${(1 - progress) * 8}px)` }, + }, + }), +}); + +/** A deliberately expressive transition for the four-direction gesture lab. */ +export const cardFlip = defineOriginChoreography({ + name: "card-flip", + commitThreshold: 0.42, + effects: ({ progress }) => ({ + source: { + transform: `perspective(900px) rotateY(${-progress * 86}deg) scale(${1 - progress * 0.08})`, + opacity: 1 - progress * 0.68, + style: { transformOrigin: "left center" }, + }, + target: { + transform: `perspective(900px) rotateY(${(1 - progress) * 86}deg) scale(${0.92 + progress * 0.08})`, + opacity: 0.3 + progress * 0.7, + style: { transformOrigin: "right center" }, + }, + }), +}); + +/** + * The frame contribution affects both sides of the edge. If this operation is + * chained, descendants inherit the same small vertical arc. + */ +export const arcSlide = defineOriginChoreography({ + name: "arc-slide", + commitThreshold: 0.34, + commitVelocity: 0.8, + effects: ({ progress }) => ({ + frame: { + transform: `translate3d(0, ${-Math.sin(progress * Math.PI) * 9}px, 0)`, + }, + source: { + transform: `translate3d(${percent(progress * -0.28)}, 0, 0) rotate(${-progress * 1.5}deg) scale(${1 - progress * 0.035})`, + opacity: 1 - progress * 0.22, + }, + target: { + transform: `translate3d(${percent(1 - progress)}, 0, 0) rotate(${(1 - progress) * 2.5}deg)`, + }, + }), +}); + +export const rise = defineOriginChoreography({ + name: "rise", + commitThreshold: 0.33, + effects: ({ progress }) => ({ + source: { + transform: `translate3d(0, ${percent(progress * -0.18)}, 0) scale(${1 - progress * 0.04})`, + opacity: 1 - progress * 0.3, + }, + target: { + transform: `translate3d(0, ${percent(1 - progress)}, 0)`, + }, + }), +}); + +export const fall = defineOriginChoreography({ + name: "fall", + commitThreshold: 0.33, + effects: ({ progress }) => ({ + source: { + transform: `translate3d(0, ${percent(progress)}, 0)`, + }, + target: { + transform: `translate3d(0, ${percent(-0.18 + progress * 0.18)}, 0) scale(${0.96 + progress * 0.04})`, + opacity: 0.7 + progress * 0.3, + }, + }), +}); + +/** + * Reveal a full-screen overlay from above while its actual dialog remains + * anchored to the left. The gesture's custom start predicate lives in the + * originating view; choreography remains concerned only with movement. + */ +export const dropEdgeDialog = defineOriginChoreography({ + name: "drop-edge-dialog", + commitThreshold: 0.24, + commitVelocity: 0.62, + effects: ({ progress }) => ({ + source: { + transform: `scale(${1 - progress * 0.025})`, + opacity: 1 - progress * 0.38, + style: { + filter: `saturate(${1 - progress * 0.35})`, + }, + }, + target: { + transform: `translate3d(0, ${percent((1 - progress) * -0.018)}, 0)`, + opacity: 1, + style: { + clipPath: `inset(0 0 ${percent(1 - progress)} 0)`, + filter: `drop-shadow(0 28px 70px rgb(0 0 0 / ${progress * 0.55}))`, + }, + }, + }), +}); + +/** Reverse the edge dialog upward and reveal its retained origin instance. */ +export const liftEdgeDialog = defineOriginChoreography({ + name: "lift-edge-dialog", + commitThreshold: 0.24, + commitVelocity: 0.62, + effects: ({ progress }) => ({ + source: { + transform: `translate3d(0, ${percent(progress * -0.018)}, 0)`, + opacity: 1, + style: { + clipPath: `inset(0 0 ${percent(progress)} 0)`, + }, + }, + target: { + transform: `scale(${0.975 + progress * 0.025})`, + opacity: 0.62 + progress * 0.38, + style: { + filter: `saturate(${0.65 + progress * 0.35})`, + }, + }, + }), +}); + +/** + * Compact horizontal motion for a nested carousel. Its smaller source travel + * keeps the retained slide visible behind the incoming card. + */ +export const nestedCarouselForward = defineOriginChoreography({ + name: "nested-carousel-forward", + commitThreshold: 0.3, + commitVelocity: 0.7, + effects: ({ progress }) => ({ + source: { + transform: `translate3d(${percent(progress * -0.18)}, 0, 0) scale(${1 - progress * 0.045})`, + opacity: 1 - progress * 0.35, + style: { filter: `saturate(${1 - progress * 0.25})` }, + }, + target: { + transform: `translate3d(${percent(1 - progress)}, 0, 0)`, + style: { + borderRadius: `${(1 - progress) * 28}px`, + boxShadow: `-24px 0 60px rgb(0 0 0 / ${progress * 0.3})`, + }, + }, + }), +}); + +/** Reverse reveal for the cooperative nested carousel. */ +export const nestedCarouselBack = defineOriginChoreography({ + name: "nested-carousel-back", + commitThreshold: 0.3, + commitVelocity: 0.7, + effects: ({ progress }) => ({ + source: { + transform: `translate3d(${percent(progress)}, 0, 0)`, + style: { borderRadius: `${progress * 28}px` }, + }, + target: { + transform: `translate3d(${percent(-0.18 + progress * 0.18)}, 0, 0) scale(${0.955 + progress * 0.045})`, + opacity: 0.65 + progress * 0.35, + style: { filter: `saturate(${0.75 + progress * 0.25})` }, + }, + }), +}); + +/** + * Vertical nested-deck motion with a shared sideways arc. The frame effect + * demonstrates that nested scenes retain the same composition semantics. + */ +export const nestedDeckForward = defineOriginChoreography({ + name: "nested-deck-forward", + commitThreshold: 0.32, + effects: ({ progress }) => ({ + frame: { + transform: `translate3d(${Math.sin(progress * Math.PI) * 10}px, 0, 0)`, + }, + source: { + transform: `translate3d(0, ${percent(progress * -0.12)}, 0) rotate(${-progress * 2}deg) scale(${1 - progress * 0.06})`, + opacity: 1 - progress * 0.4, + }, + target: { + transform: `translate3d(0, ${percent(1 - progress)}, 0) rotate(${(1 - progress) * 3}deg)`, + }, + }), +}); + +/** Downward reverse of the nested vertical deck. */ +export const nestedDeckBack = defineOriginChoreography({ + name: "nested-deck-back", + commitThreshold: 0.32, + effects: ({ progress }) => ({ + frame: { + transform: `translate3d(${-Math.sin(progress * Math.PI) * 10}px, 0, 0)`, + }, + source: { + transform: `translate3d(0, ${percent(progress)}, 0) rotate(${progress * 3}deg)`, + }, + target: { + transform: `translate3d(0, ${percent(-0.12 + progress * 0.12)}, 0) rotate(${(1 - progress) * -2}deg) scale(${0.94 + progress * 0.06})`, + opacity: 0.6 + progress * 0.4, + }, + }), +}); + +/** + * Intentionally dramatic cube turn used by the conflict case so it is obvious + * when the child scene, rather than the page scene, owns the gesture. + */ +export const nestedCubeForward = defineOriginChoreography({ + name: "nested-cube-forward", + commitThreshold: 0.38, + effects: ({ progress }) => ({ + source: { + transform: `perspective(720px) translate3d(${percent(progress * -0.5)}, 0, 0) rotateY(${progress * 72}deg)`, + opacity: 1 - progress * 0.55, + style: { transformOrigin: "right center" }, + }, + target: { + transform: `perspective(720px) translate3d(${percent((1 - progress) * 0.5)}, 0, 0) rotateY(${(progress - 1) * 72}deg)`, + opacity: 0.45 + progress * 0.55, + style: { transformOrigin: "left center" }, + }, + }), +}); + +/** Reverse cube turn for retained slides in the conflict case. */ +export const nestedCubeBack = defineOriginChoreography({ + name: "nested-cube-back", + commitThreshold: 0.38, + effects: ({ progress }) => ({ + source: { + transform: `perspective(720px) translate3d(${percent(progress * 0.5)}, 0, 0) rotateY(${-progress * 72}deg)`, + opacity: 1 - progress * 0.55, + style: { transformOrigin: "left center" }, + }, + target: { + transform: `perspective(720px) translate3d(${percent((progress - 1) * 0.5)}, 0, 0) rotateY(${(1 - progress) * 72}deg)`, + opacity: 0.45 + progress * 0.55, + style: { transformOrigin: "right center" }, + }, + }), +}); diff --git a/apps/origins-demo/src/style.css b/apps/origins-demo/src/style.css new file mode 100644 index 0000000..f2860f4 --- /dev/null +++ b/apps/origins-demo/src/style.css @@ -0,0 +1,1963 @@ +@import "@native-vue-router/core-v2/style.css"; + +:root { + color: #f5f7ff; + background: #07090f; + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; + font-synthesis: none; + color-scheme: dark; +} + +* { + box-sizing: border-box; +} + +html, +body, +#app { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; +} + +button, +input { + color: inherit; + font: inherit; +} + +button { + cursor: pointer; +} + +button:focus-visible, +input:focus-visible, +summary:focus-visible { + outline: 2px solid #8edfff; + outline-offset: 3px; +} + +.demo-shell { + position: relative; + width: 100%; + height: 100%; + height: 100dvh; +} + +.lab-page { + position: relative; + display: flex; + width: 100%; + height: 100%; + flex-direction: column; + justify-content: center; + gap: 1.15rem; + padding: max(2rem, env(safe-area-inset-top)) + max(1.5rem, env(safe-area-inset-right)) + max(2rem, env(safe-area-inset-bottom)) + max(1.5rem, env(safe-area-inset-left)); + overflow: hidden; +} + +.lab-page h1, +.lab-page p { + max-width: 43rem; + margin: 0; +} + +.lab-page h1 { + font-size: clamp(2.6rem, 7vw, 6.4rem); + line-height: 0.93; + letter-spacing: -0.062em; +} + +.lab-page p { + color: rgb(255 255 255 / 68%); + font-size: clamp(1rem, 1.6vw, 1.2rem); + line-height: 1.58; +} + +.eyebrow { + color: rgb(255 255 255 / 58%); + font-size: 0.72rem; + font-weight: 780; + letter-spacing: 0.15em; + text-transform: uppercase; +} + +.primary-action, +.view-back, +.photo-toolbar button, +.player-header button, +.track-copy button, +.player-controls button, +.chat-header button, +.composer button { + border: 1px solid rgb(255 255 255 / 18%); + border-radius: 999px; + color: inherit; + background: rgb(255 255 255 / 10%); +} + +.primary-action { + align-self: flex-start; + padding: 0.82rem 1.15rem; + font-weight: 720; + backdrop-filter: blur(12px); +} + +.view-back { + position: absolute; + top: max(1.25rem, env(safe-area-inset-top)); + left: max(1.5rem, env(safe-area-inset-left)); + z-index: 2; + padding: 0.58rem 0.85rem; + font-size: 0.8rem; +} + +.instance-card { + display: grid; + grid-template-columns: auto auto; + align-items: center; + align-self: flex-start; + gap: 0.12rem 0.7rem; + min-width: 12rem; + border: 1px solid rgb(255 255 255 / 14%); + border-radius: 1rem; + padding: 0.75rem 0.9rem; + background: rgb(0 0 0 / 17%); + backdrop-filter: blur(16px); +} + +.instance-card span { + color: rgb(255 255 255 / 54%); + font-size: 0.66rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.instance-card strong { + grid-row: span 2; + grid-column: 2; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 1.15rem; +} + +.instance-card small { + color: #8dffc0; + font-size: 0.7rem; +} + +.gesture-hint { + display: flex; + align-items: center; + gap: 0.6rem; + margin-top: 0.75rem; + font-weight: 720; +} + +.gesture-hint span { + font-size: 1.55rem; +} + +.gesture-hint--back { + align-self: flex-start; +} + +.edge-marker { + position: absolute; + z-index: 4; + top: 50%; + padding: 0.35rem 0.32rem; + color: rgb(255 255 255 / 38%); + font-size: 0.54rem; + letter-spacing: 0.12em; + text-transform: uppercase; + writing-mode: vertical-rl; + pointer-events: none; + transform: translateY(-50%) rotate(180deg); +} + +.edge-marker--left { + left: 0; + border-right: 1px solid rgb(255 255 255 / 16%); +} + +.proof, +.metric-grid > div { + border: 1px solid rgb(255 255 255 / 14%); + border-radius: 1rem; + padding: 0.9rem 1rem; + background: rgb(0 0 0 / 14%); +} + +.proof strong { + margin-left: 0.35rem; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.metric-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 13rem)); + gap: 0.75rem; +} + +.metric-grid > div { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.metric-grid span { + color: rgb(255 255 255 / 52%); + font-size: 0.68rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +/* Hub */ + +.hub-view { + justify-content: flex-start; + overflow: auto; + overscroll-behavior: contain; + background: + radial-gradient(circle at 14% -10%, #313375 0, transparent 35rem), + radial-gradient(circle at 100% 70%, #173c4c 0, transparent 34rem), #090b12; +} + +.hub-header, +.section-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 2rem; + width: min(100%, 76rem); + margin: 0 auto; + padding-top: clamp(2rem, 7vh, 5rem); +} + +.hub-header > div:first-child, +.section-header > div:first-child { + display: flex; + flex-direction: column; + gap: 0.8rem; +} + +.hub-header h1 { + max-width: 62rem; + font-size: clamp(3rem, 7.4vw, 7.5rem); +} + +.hub-header p { + max-width: 52rem; +} + +.lab-grid { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: 0.85rem; + width: min(100%, 76rem); + margin: 1.2rem auto 0; +} + +.lab-card { + position: relative; + grid-column: span 3; + display: flex; + min-height: 15rem; + flex-direction: column; + align-items: flex-start; + gap: 1.2rem; + border: 1px solid rgb(255 255 255 / 13%); + border-radius: 1.4rem; + padding: 1.2rem; + color: white; + text-align: left; + background: + linear-gradient(145deg, rgb(255 255 255 / 9%), rgb(255 255 255 / 2%)), + #10131c; + box-shadow: 0 1.3rem 4rem rgb(0 0 0 / 12%); + overflow: hidden; + transition: + border-color 180ms ease, + transform 180ms ease; +} + +.lab-card:nth-child(1), +.lab-card:nth-child(2) { + grid-column: span 6; +} + +.lab-card:hover { + border-color: rgb(255 255 255 / 30%); + transform: translateY(-3px); +} + +.lab-card::after { + position: absolute; + right: -3rem; + bottom: -5rem; + width: 12rem; + height: 12rem; + border-radius: 50%; + background: var(--lab-glow); + filter: blur(30px); + content: ""; + opacity: 0.42; +} + +.lab-card--violet { + --lab-glow: #8c57ff; +} + +.lab-card--cyan { + --lab-glow: #28d5ff; +} + +.lab-card--amber { + --lab-glow: #ff9f3d; +} + +.lab-card--green { + --lab-glow: #53e69b; +} + +.lab-card--rose { + --lab-glow: #ff527b; +} + +.lab-card--lime { + --lab-glow: #c9ff55; +} + +.lab-card--blue { + --lab-glow: #438cff; +} + +.lab-number { + color: rgb(255 255 255 / 42%); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.72rem; +} + +.lab-copy { + display: flex; + flex-direction: column; + gap: 0.55rem; + max-width: 25rem; +} + +.lab-copy strong { + font-size: clamp(1.5rem, 2.4vw, 2.4rem); + letter-spacing: -0.045em; +} + +.lab-copy small { + color: rgb(255 255 255 / 58%); + font-size: 0.9rem; + line-height: 1.5; +} + +.tag-row { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-top: auto; +} + +.tag-row em { + border: 1px solid rgb(255 255 255 / 12%); + border-radius: 999px; + padding: 0.28rem 0.5rem; + color: rgb(255 255 255 / 55%); + font-size: 0.62rem; + font-style: normal; +} + +.lab-arrow { + position: absolute; + top: 1rem; + right: 1.1rem; + font-size: 1.25rem; +} + +.hub-footer { + display: flex; + justify-content: space-between; + gap: 1rem; + width: min(100%, 76rem); + margin: 0.8rem auto 0; + padding-bottom: 1rem; + color: rgb(255 255 255 / 40%); + font-size: 0.7rem; +} + +/* Scene inspector */ + +.scene-debug { + position: fixed; + z-index: 20; + top: max(0.75rem, env(safe-area-inset-top)); + right: max(0.75rem, env(safe-area-inset-right)); + width: min(20rem, calc(100vw - 1.5rem)); + border: 1px solid rgb(255 255 255 / 14%); + border-radius: 1rem; + color: rgb(255 255 255 / 70%); + background: rgb(7 9 15 / 82%); + box-shadow: 0 0.8rem 2.5rem rgb(0 0 0 / 28%); + backdrop-filter: blur(18px); + font-size: 0.7rem; +} + +.scene-debug summary { + display: flex; + align-items: center; + gap: 0.45rem; + padding: 0.65rem 0.8rem; + color: white; + font-weight: 720; + list-style: none; + cursor: pointer; +} + +.scene-debug summary::-webkit-details-marker { + display: none; +} + +.debug-pulse { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: #5f6b7b; +} + +.debug-pulse.live { + background: #65ffac; + box-shadow: 0 0 0 0.25rem rgb(101 255 172 / 13%); +} + +.debug-content { + display: flex; + max-height: min(70vh, 36rem); + flex-direction: column; + gap: 0.9rem; + padding: 0 0.8rem 0.8rem; + overflow: auto; +} + +.debug-content section { + display: flex; + flex-direction: column; + gap: 0.4rem; + border-top: 1px solid rgb(255 255 255 / 9%); + padding-top: 0.7rem; +} + +.debug-content ol, +.event-log { + display: flex; + flex-direction: column; + gap: 0.3rem; + margin: 0; + padding: 0; + list-style: none; +} + +.debug-content ol li { + display: flex; + justify-content: space-between; +} + +.debug-content ol li > span { + display: flex; + align-items: center; + gap: 0.35rem; +} + +.node-state { + border-radius: 999px; + padding: 0.12rem 0.3rem; + color: rgb(255 255 255 / 46%); + background: rgb(255 255 255 / 7%); + font-size: 0.5rem; + font-style: normal; + text-transform: uppercase; +} + +.node-state--active { + color: #72ffb2; +} + +.node-state--transitioning { + color: #8edfff; +} + +.debug-content code { + color: #8edfff; +} + +.debug-operation { + display: grid; + grid-template-columns: 1fr auto; + gap: 0.25rem 0.5rem; +} + +.debug-operation progress { + grid-column: 1 / -1; + width: 100%; + height: 0.3rem; + accent-color: #65ffac; +} + +.debug-operation small { + grid-column: 1 / -1; + color: rgb(255 255 255 / 42%); +} + +.event-log li { + color: rgb(255 255 255 / 52%); +} + +.event-log time { + margin-right: 0.35rem; + color: rgb(255 255 255 / 28%); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} + +/* Concurrent chain */ + +.chain-view { + padding-left: max(2.4rem, env(safe-area-inset-left)); +} + +.chain-view h1 { + max-width: 54rem; +} + +.chain-view p { + max-width: 40rem; +} + +.view-one { + background: + radial-gradient(circle at 80% 15%, #6b36a2 0, transparent 39%), + linear-gradient(145deg, #24133b, #0d0e18 70%); +} + +.view-two { + background: + radial-gradient(circle at 20% 15%, #1976a9 0, transparent 42%), + linear-gradient(145deg, #073b5a, #081525 70%); +} + +.view-three { + background: + radial-gradient(circle at 80% 15%, #c96f2c 0, transparent 40%), + linear-gradient(145deg, #713817, #1d110c 70%); +} + +.view-four { + background: + radial-gradient(circle at 20% 20%, #d8ff86 0, transparent 34%), + linear-gradient(145deg, #294a3a, #08140f 70%); +} + +/* Direction matrix */ + +.direction-view { + background: + linear-gradient(rgb(255 255 255 / 3%) 1px, transparent 1px), + linear-gradient(90deg, rgb(255 255 255 / 3%) 1px, transparent 1px), + radial-gradient(circle at 80% 20%, #163c55 0, transparent 38rem), #090d14; + background-size: + 3rem 3rem, + 3rem 3rem, + auto, + auto; +} + +.direction-compass { + position: absolute; + right: clamp(2rem, 10vw, 10rem); + bottom: clamp(4rem, 12vh, 9rem); + display: grid; + grid-template: + ". up ." 7rem + "left core ." 8rem / + 8rem 8rem 1fr; + gap: 0.75rem; +} + +.compass-action, +.compass-core { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + border: 1px solid rgb(255 255 255 / 15%); + border-radius: 1.4rem; + color: white; + background: rgb(255 255 255 / 7%); + backdrop-filter: blur(12px); +} + +.compass-action { + gap: 0.2rem; +} + +.compass-action span { + font-size: 1.4rem; +} + +.compass-action small, +.compass-core span { + color: rgb(255 255 255 / 42%); + font-size: 0.58rem; + text-transform: uppercase; +} + +.compass-action--up { + grid-area: up; +} + +.compass-action--left { + grid-area: left; +} + +.compass-core { + grid-area: core; + border-radius: 50%; + background: rgb(56 216 255 / 12%); + box-shadow: inset 0 0 2rem rgb(56 216 255 / 8%); +} + +.gesture-map { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.gesture-map span { + border: 1px solid rgb(255 255 255 / 12%); + border-radius: 999px; + padding: 0.35rem 0.55rem; + color: rgb(255 255 255 / 52%); + font-size: 0.66rem; +} + +.direction-result { + background: + radial-gradient( + circle at 70% 30%, + color-mix(in srgb, var(--result-accent), transparent 45%), + transparent 35rem + ), + #091019; +} + +.direction-result h1, +.direction-result p, +.direction-result .instance-card, +.direction-result .primary-action, +.direction-result .gesture-hint { + position: relative; + z-index: 1; +} + +.result-orbit { + position: absolute; + right: -12vw; + bottom: -24vw; + width: min(74vw, 50rem); + aspect-ratio: 1; + border: 1px solid color-mix(in srgb, var(--result-accent), transparent 65%); + border-radius: 50%; + box-shadow: + 0 0 0 4rem color-mix(in srgb, var(--result-accent), transparent 94%), + 0 0 0 9rem color-mix(in srgb, var(--result-accent), transparent 97%); +} + +/* Gallery */ + +.gallery-view { + justify-content: flex-start; + overflow: auto; + background: + radial-gradient(circle at 90% 0, #4a2d22 0, transparent 34rem), #0c0d12; +} + +.section-header { + align-items: flex-end; + padding-top: clamp(3rem, 8vh, 6rem); +} + +.section-header h1 { + max-width: 52rem; + font-size: clamp(2.6rem, 5.6vw, 5.5rem); +} + +.photo-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.85rem; + width: min(100%, 76rem); + margin: 1.5rem auto 0; + padding-bottom: 2rem; +} + +.photo-card { + position: relative; + display: flex; + min-height: clamp(18rem, 45vh, 34rem); + flex-direction: column; + align-items: flex-start; + justify-content: flex-end; + gap: 0.25rem; + border: 1px solid rgb(255 255 255 / 16%); + border-radius: 1.4rem; + padding: 1.15rem; + color: white; + text-align: left; + box-shadow: 0 1.3rem 3rem rgb(0 0 0 / 18%); + overflow: hidden; + transition: transform 180ms ease; +} + +.photo-card:hover { + transform: translateY(-4px) scale(1.006); +} + +.photo-card::after { + position: absolute; + inset: 45% 0 0; + background: linear-gradient(transparent, rgb(0 0 0 / 62%)); + content: ""; +} + +.photo-card > * { + position: relative; + z-index: 1; +} + +.photo-card span { + color: rgb(255 255 255 / 52%); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.66rem; +} + +.photo-card strong { + font-size: clamp(1.7rem, 3vw, 3rem); + letter-spacing: -0.05em; +} + +.photo-card small { + color: rgb(255 255 255 / 62%); +} + +.photo-detail { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; +} + +.photo-vignette { + position: absolute; + inset: 0; + background: + linear-gradient(90deg, rgb(0 0 0 / 58%), transparent 70%), + linear-gradient(0deg, rgb(0 0 0 / 45%), transparent 48%); +} + +.photo-toolbar { + position: absolute; + z-index: 2; + top: max(1.2rem, env(safe-area-inset-top)); + right: max(1.2rem, env(safe-area-inset-right)); + left: max(1.2rem, env(safe-area-inset-left)); + display: flex; + justify-content: space-between; +} + +.photo-toolbar button { + padding: 0.55rem 0.8rem; + backdrop-filter: blur(14px); +} + +.photo-copy { + position: absolute; + z-index: 1; + right: max(1.5rem, env(safe-area-inset-right)); + bottom: max(2rem, env(safe-area-inset-bottom)); + left: max(1.5rem, env(safe-area-inset-left)); + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +.photo-copy h1, +.photo-copy p { + max-width: 42rem; + margin: 0; +} + +.photo-copy h1 { + font-size: clamp(3.5rem, 11vw, 9rem); + line-height: 0.84; + letter-spacing: -0.075em; +} + +.photo-copy p { + color: rgb(255 255 255 / 72%); + line-height: 1.55; +} + +/* Player */ + +.player-view { + position: relative; + display: flex; + width: 100%; + height: 100%; + flex-direction: column; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: max(1.4rem, env(safe-area-inset-top)) + max(1.5rem, env(safe-area-inset-right)) + max(1.5rem, env(safe-area-inset-bottom)) + max(1.5rem, env(safe-area-inset-left)); + background: + radial-gradient(circle at 50% 38%, #345f4c 0, transparent 34rem), + linear-gradient(#183528, #08130e); +} + +.player-header, +.track-copy, +.timeline, +.player-controls { + display: flex; + align-items: center; + width: min(100%, 34rem); +} + +.player-header { + justify-content: space-between; + font-size: 0.8rem; + font-weight: 720; + text-transform: uppercase; +} + +.player-header button, +.track-copy button, +.player-controls button { + display: grid; + width: 2.7rem; + height: 2.7rem; + padding: 0; + place-items: center; +} + +.album-art { + position: relative; + display: grid; + width: min(63vw, 25rem); + aspect-ratio: 1; + border: 1px solid rgb(255 255 255 / 14%); + border-radius: 2.2rem; + place-items: center; + background: + radial-gradient(circle at 30% 24%, #c9ff9e 0, transparent 20%), + conic-gradient(from 40deg, #1d5840, #86c179, #153a2a, #d2ed9d, #1d5840); + box-shadow: 0 2.4rem 7rem rgb(0 0 0 / 35%); + overflow: hidden; +} + +.album-ring { + position: absolute; + border: 1px solid rgb(255 255 255 / 28%); + border-radius: 50%; +} + +.album-ring--one { + width: 74%; + height: 74%; +} + +.album-ring--two { + width: 48%; + height: 48%; +} + +.album-center { + display: grid; + width: 5rem; + height: 5rem; + border-radius: 50%; + color: #102519; + background: #d9ffaf; + place-items: center; + font-size: 2rem; + font-weight: 850; +} + +.track-copy { + justify-content: space-between; +} + +.track-copy h1, +.track-copy p { + margin: 0; +} + +.track-copy h1 { + font-size: clamp(1.7rem, 4vw, 2.4rem); + letter-spacing: -0.045em; +} + +.track-copy p { + margin-top: 0.2rem; + color: rgb(255 255 255 / 52%); +} + +.timeline { + display: grid; + grid-template-columns: 1fr auto; + gap: 0.3rem 0.7rem; + color: rgb(255 255 255 / 48%); + font-size: 0.66rem; +} + +.timeline input { + grid-column: 1 / -1; + width: 100%; + accent-color: #d9ffaf; +} + +.timeline span:last-child { + text-align: right; +} + +.player-controls { + justify-content: center; + gap: 1.5rem; +} + +.player-controls .play-button { + width: 4rem; + height: 4rem; + color: #102519; + background: #d9ffaf; + font-size: 1.2rem; +} + +.policy-note { + max-width: 28rem; + margin: 0; + color: rgb(255 255 255 / 48%); + text-align: center; + font-size: 0.74rem; +} + +.drag-handle { + position: absolute; + top: 0.5rem; + width: 2.5rem; + height: 0.25rem; + border-radius: 999px; + background: rgb(255 255 255 / 30%); +} + +/* Locked chat */ + +.chat-view { + display: grid; + width: 100%; + height: 100%; + grid-template-rows: auto 1fr auto; + color: #f5f7ff; + background: + radial-gradient(circle at 50% 0, #3a1e48 0, transparent 28rem), #0c0b12; +} + +.chat-header { + display: flex; + align-items: center; + gap: 0.75rem; + padding: max(1rem, env(safe-area-inset-top)) + max(1rem, env(safe-area-inset-right)) 0.8rem + max(1rem, env(safe-area-inset-left)); + border-bottom: 1px solid rgb(255 255 255 / 9%); + background: rgb(9 8 14 / 62%); + backdrop-filter: blur(18px); +} + +.chat-header button { + width: 2.5rem; + height: 2.5rem; +} + +.chat-avatar { + display: grid; + width: 2.4rem; + height: 2.4rem; + border-radius: 50%; + background: linear-gradient(145deg, #ff8dce, #8a55ff); + place-items: center; + font-weight: 850; +} + +.chat-header > div:nth-child(3) { + display: flex; + flex-direction: column; +} + +.chat-header span { + color: #8dffc0; + font-size: 0.65rem; +} + +.chat-lock { + margin-left: auto; + border: 1px solid rgb(255 255 255 / 12%); + border-radius: 999px; + padding: 0.3rem 0.5rem; + color: rgb(255 255 255 / 45%) !important; + text-transform: uppercase; +} + +.messages { + display: flex; + flex-direction: column; + gap: 0.6rem; + padding: 1rem max(1rem, env(safe-area-inset-right)) 1rem + max(1rem, env(safe-area-inset-left)); + overflow: auto; +} + +.message { + align-self: flex-start; + max-width: min(80%, 32rem); + border-radius: 1rem 1rem 1rem 0.25rem; + padding: 0.75rem 0.9rem; + background: rgb(255 255 255 / 10%); + line-height: 1.45; +} + +.message--mine { + align-self: flex-end; + border-radius: 1rem 1rem 0.25rem; + background: #7449d8; +} + +.messages .instance-card { + margin-top: auto; +} + +.composer { + display: flex; + gap: 0.6rem; + padding: 0.75rem max(1rem, env(safe-area-inset-right)) + max(0.75rem, env(safe-area-inset-bottom)) + max(1rem, env(safe-area-inset-left)); + border-top: 1px solid rgb(255 255 255 / 9%); +} + +.composer input { + min-width: 0; + flex: 1; + border: 1px solid rgb(255 255 255 / 13%); + border-radius: 999px; + padding: 0.75rem 0.95rem; + background: rgb(255 255 255 / 8%); +} + +.composer button { + padding: 0.7rem 1rem; + background: #7449d8; +} + +/* Custom predicate edge */ + +.predicate-view { + justify-content: center; + touch-action: none; + -webkit-user-select: none; + user-select: none; + background: + linear-gradient(rgb(255 255 255 / 3%) 1px, transparent 1px), + linear-gradient(90deg, rgb(255 255 255 / 3%) 1px, transparent 1px), + radial-gradient(circle at 0 50%, #52731d 0, transparent 32rem), #0a0f0b; + background-size: + 3rem 3rem, + 3rem 3rem, + auto, + auto; +} + +.predicate-copy { + display: flex; + max-width: 55rem; + flex-direction: column; + gap: 1rem; + padding-left: clamp(3rem, 9vw, 5.5rem); +} + +.predicate-copy h1, +.predicate-copy p { + max-width: 48rem; +} + +.predicate-copy p strong { + color: #dcff8f; +} + +.predicate-code { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + align-self: flex-start; + gap: 0.3rem 0.8rem; + border: 1px solid rgb(220 255 143 / 22%); + border-radius: 1rem; + padding: 0.8rem 1rem; + background: rgb(4 10 5 / 42%); + backdrop-filter: blur(16px); +} + +.predicate-code span { + color: rgb(255 255 255 / 42%); + font-size: 0.62rem; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.predicate-code code { + color: #dcff8f; + font-size: 0.82rem; +} + +.predicate-code small { + grid-column: 1 / -1; + color: rgb(255 255 255 / 45%); +} + +.predicate-edge { + position: absolute; + z-index: 2; + inset: 0 auto 0 0; + display: flex; + width: clamp(2.25rem, 8vw, 4.5rem); + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + border-right: 1px solid rgb(220 255 143 / 28%); + color: #e4ffa6; + background: + linear-gradient(90deg, rgb(201 255 85 / 20%), rgb(201 255 85 / 3%)), + repeating-linear-gradient( + 0deg, + transparent 0 1.5rem, + rgb(255 255 255 / 5%) 1.5rem calc(1.5rem + 1px) + ); + box-shadow: 0 0 3rem rgb(161 255 53 / 10%); + pointer-events: none; +} + +.predicate-edge span, +.predicate-edge small { + font-size: 0.55rem; + font-weight: 760; + letter-spacing: 0.13em; + text-transform: uppercase; + writing-mode: vertical-rl; + transform: rotate(180deg); +} + +.predicate-edge small { + color: rgb(255 255 255 / 44%); +} + +.predicate-arrow { + font-size: 2rem; + animation: predicate-pull 1.6s ease-in-out infinite; +} + +@keyframes predicate-pull { + 0%, + 100% { + transform: translateY(-0.4rem); + opacity: 0.45; + } + 50% { + transform: translateY(0.65rem); + opacity: 1; + } +} + +.predicate-axis-note { + position: absolute; + right: max(1.5rem, env(safe-area-inset-right)); + bottom: max(1.5rem, env(safe-area-inset-bottom)); + color: rgb(255 255 255 / 38%); + font-size: 0.68rem; +} + +/* Nested scenes */ + +.nested-scenes-view { + justify-content: flex-start; + gap: 1.5rem; + overflow: auto; + overscroll-behavior: contain; + background: + linear-gradient(rgb(255 255 255 / 2.5%) 1px, transparent 1px), + linear-gradient(90deg, rgb(255 255 255 / 2.5%) 1px, transparent 1px), + radial-gradient(circle at 15% 0, #183d64 0, transparent 34rem), + radial-gradient(circle at 92% 65%, #4a1627 0, transparent 32rem), #080b11; + background-size: + 3.5rem 3.5rem, + 3.5rem 3.5rem, + auto, + auto, + auto; +} + +.nested-scenes-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 2rem; + width: min(100%, 88rem); + margin: 0 auto; + padding-top: clamp(3rem, 7vh, 5rem); +} + +.nested-scenes-header > div:first-child { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.nested-scenes-header h1 { + max-width: 62rem; + font-size: clamp(3rem, 6.7vw, 7rem); +} + +.nested-scenes-header p { + max-width: 57rem; +} + +.nested-scenes-header code { + color: #8edfff; +} + +.nested-page-instance { + display: flex; + min-width: 13rem; + flex-direction: column; + gap: 0.2rem; + border: 1px solid rgb(142 223 255 / 18%); + border-radius: 1rem; + padding: 0.85rem 1rem; + background: rgb(4 12 22 / 55%); + backdrop-filter: blur(16px); +} + +.nested-page-instance span, +.nested-page-instance small { + color: rgb(255 255 255 / 46%); + font-size: 0.63rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.nested-page-instance strong { + color: #8edfff; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.nested-case-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; + width: min(100%, 88rem); + margin: 0 auto; +} + +.nested-case { + display: flex; + min-width: 0; + flex-direction: column; + gap: 0.9rem; + border: 1px solid rgb(255 255 255 / 12%); + border-radius: 1.5rem; + padding: 1rem; + background: rgb(11 15 24 / 82%); + box-shadow: 0 1.5rem 4rem rgb(0 0 0 / 18%); +} + +.nested-case--conflict { + border-color: rgb(255 95 120 / 30%); + background: + linear-gradient(145deg, rgb(255 95 120 / 9%), transparent 38%), + rgb(18 11 17 / 88%); +} + +.nested-case > header { + display: flex; + min-height: 10.5rem; + flex-direction: column; + align-items: flex-start; + gap: 0.55rem; +} + +.nested-case h2, +.nested-case p { + margin: 0; +} + +.nested-case h2 { + font-size: clamp(1.45rem, 2vw, 2rem); + letter-spacing: -0.035em; +} + +.nested-case p { + color: rgb(255 255 255 / 55%); + font-size: 0.83rem; + line-height: 1.5; +} + +.nested-case__status { + border: 1px solid rgb(101 242 177 / 24%); + border-radius: 999px; + padding: 0.28rem 0.55rem; + color: #65f2b1; + background: rgb(101 242 177 / 8%); + font-size: 0.58rem; + font-weight: 780; + letter-spacing: 0.09em; + text-transform: uppercase; +} + +.nested-case__status.supported-with-policy { + border-color: rgb(118 220 255 / 25%); + color: #8edfff; + background: rgb(118 220 255 / 8%); +} + +.nested-case__status.known-conflict { + border-color: rgb(255 95 120 / 28%); + color: #ff8b9d; + background: rgb(255 95 120 / 10%); +} + +.nested-stage-shell { + position: relative; + height: clamp(19rem, 40vh, 25rem); + min-height: 19rem; + border: 1px solid rgb(255 255 255 / 15%); + border-radius: 1.2rem; + background: #070a10; + box-shadow: inset 0 0 0 1px rgb(0 0 0 / 40%); + overflow: hidden; + isolation: isolate; +} + +.nested-stage { + border-radius: inherit; + background: #090d15; +} + +.nested-stage-edge { + position: absolute; + z-index: 8; + inset: 0 auto 0 0; + display: flex; + width: clamp(22px, 12%, 52px); + align-items: center; + justify-content: center; + border-right: 1px dashed rgb(142 223 255 / 27%); + color: rgb(142 223 255 / 48%); + background: linear-gradient(90deg, rgb(80 178 255 / 15%), transparent); + font-size: 0.51rem; + font-weight: 760; + letter-spacing: 0.09em; + text-transform: uppercase; + writing-mode: vertical-rl; + pointer-events: none; +} + +.nested-case--conflict .nested-stage-edge { + border-color: rgb(255 95 120 / 38%); + color: rgb(255 139 157 / 70%); + background: linear-gradient(90deg, rgb(255 95 120 / 20%), transparent); +} + +.nested-case__diagnostics { + display: flex; + justify-content: space-between; + gap: 0.5rem; + color: rgb(255 255 255 / 42%); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.62rem; +} + +.nested-case dl { + display: flex; + flex-direction: column; + gap: 0.6rem; + margin: 0; +} + +.nested-case dl > div { + display: grid; + grid-template-columns: 6.7rem 1fr; + gap: 0.7rem; + border-top: 1px solid rgb(255 255 255 / 8%); + padding-top: 0.6rem; +} + +.nested-case dt { + color: rgb(255 255 255 / 40%); + font-size: 0.58rem; + font-weight: 760; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.nested-case dd { + margin: 0; + color: rgb(255 255 255 / 62%); + font-size: 0.7rem; + line-height: 1.45; +} + +.nested-case--conflict dl > div:last-child dd { + color: #ff9cab; +} + +.nested-slide { + --nested-accent: #8edfff; + position: relative; + display: flex; + width: 100%; + height: 100%; + flex-direction: column; + justify-content: space-between; + gap: 0.75rem; + padding: 1rem 1rem 0.9rem 3.5rem; + background: + linear-gradient( + 145deg, + color-mix(in srgb, var(--nested-accent) 18%, transparent), + transparent 46% + ), + radial-gradient( + circle at 90% 5%, + color-mix(in srgb, var(--nested-accent) 36%, transparent), + transparent 12rem + ), + #111723; + overflow: hidden; + -webkit-user-select: none; + user-select: none; +} + +.nested-slide--vertical { + background: + radial-gradient( + circle at 80% 15%, + color-mix(in srgb, var(--nested-accent) 30%, transparent), + transparent 11rem + ), + linear-gradient(165deg, #201712, #0e121a); +} + +.nested-slide--conflict { + background: + repeating-linear-gradient( + 135deg, + rgb(255 255 255 / 3%) 0 1px, + transparent 1px 18px + ), + radial-gradient( + circle at 85% 10%, + color-mix(in srgb, var(--nested-accent) 32%, transparent), + transparent 12rem + ), + #1b1017; +} + +.nested-slide__orb { + position: absolute; + right: -3.5rem; + bottom: -4.5rem; + width: 12rem; + height: 12rem; + border: 1px solid color-mix(in srgb, var(--nested-accent) 44%, transparent); + border-radius: 38% 62% 55% 45%; + box-shadow: + 0 0 0 1.4rem color-mix(in srgb, var(--nested-accent) 5%, transparent), + 0 0 0 3rem color-mix(in srgb, var(--nested-accent) 3%, transparent); + transform: rotate(24deg); +} + +.nested-slide > header, +.nested-slide > footer, +.nested-slide__copy, +.nested-slide__state { + position: relative; + z-index: 1; +} + +.nested-slide > header, +.nested-slide > footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.nested-slide > header span { + color: var(--nested-accent); + font-size: 0.58rem; + font-weight: 800; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.nested-slide > header strong { + color: rgb(255 255 255 / 48%); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.66rem; +} + +.nested-slide__copy { + display: flex; + flex-direction: column; + gap: 0.45rem; +} + +.nested-slide__copy h3, +.nested-slide__copy p { + margin: 0; +} + +.nested-slide__copy h3 { + max-width: 18rem; + font-size: clamp(1.65rem, 3vw, 2.5rem); + line-height: 0.96; + letter-spacing: -0.05em; +} + +.nested-slide__copy p { + max-width: 22rem; + color: rgb(255 255 255 / 61%); + font-size: 0.75rem; + line-height: 1.45; +} + +.nested-slide__state { + display: flex; + align-items: flex-end; + gap: 0.6rem; +} + +.nested-slide .instance-card { + min-width: 10.5rem; + padding: 0.55rem 0.7rem; +} + +.nested-slide .instance-card strong { + font-size: 0.85rem; +} + +.nested-slide button { + border: 1px solid rgb(255 255 255 / 14%); + border-radius: 999px; + padding: 0.48rem 0.7rem; + color: rgb(255 255 255 / 72%); + background: rgb(0 0 0 / 20%); + font-size: 0.62rem; + backdrop-filter: blur(10px); +} + +.nested-slide button:disabled { + opacity: 0.3; + cursor: default; +} + +.nested-slide__dots { + display: flex; + gap: 0.28rem; +} + +.nested-slide__dots span { + width: 0.3rem; + height: 0.3rem; + border-radius: 50%; + background: rgb(255 255 255 / 20%); +} + +.nested-slide__dots span.active { + background: var(--nested-accent); + box-shadow: 0 0 0 0.2rem + color-mix(in srgb, var(--nested-accent) 12%, transparent); +} + +.nested-scenes-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + width: min(100%, 88rem); + margin: 0 auto; + padding: 0.4rem 0 1rem; + color: rgb(255 255 255 / 45%); + font-size: 0.72rem; +} + +.nested-scenes-footer strong { + color: #ff9cab; +} + +.nested-page-gesture { + position: fixed; + z-index: 5; + right: max(1rem, env(safe-area-inset-right)); + bottom: max(0.75rem, env(safe-area-inset-bottom)); + border: 1px solid rgb(142 223 255 / 17%); + border-radius: 999px; + padding: 0.45rem 0.7rem; + color: rgb(142 223 255 / 58%); + background: rgb(4 12 22 / 68%); + font-size: 0.58rem; + letter-spacing: 0.06em; + text-transform: uppercase; + pointer-events: none; + backdrop-filter: blur(12px); +} + +.edge-dialog-overlay { + position: relative; + width: 100%; + height: 100%; + color: #f7faef; + overflow: hidden; +} + +.edge-dialog-backdrop { + position: absolute; + inset: 0; + border: 0; + border-radius: 0; + background: rgb(2 7 4 / 68%); + backdrop-filter: blur(12px); +} + +.edge-dialog { + position: absolute; + z-index: 1; + inset: 0 auto 0 0; + display: flex; + width: min(38rem, 78vw); + flex-direction: column; + gap: 1.15rem; + border: 0; + border-right: 1px solid rgb(220 255 143 / 22%); + padding: max(2rem, env(safe-area-inset-top)) + max(1.5rem, env(safe-area-inset-right)) + max(2rem, env(safe-area-inset-bottom)) + max(1.5rem, env(safe-area-inset-left)); + background: + radial-gradient(circle at 0 0, #648f29 0, transparent 27rem), + linear-gradient(155deg, #1a2a12, #081009 72%); + box-shadow: 2rem 0 7rem rgb(0 0 0 / 42%); + overflow: auto; +} + +.edge-dialog header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.edge-dialog header > div { + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.edge-dialog h1, +.edge-dialog p { + margin: 0; +} + +.edge-dialog h1 { + max-width: 29rem; + font-size: clamp(3rem, 6vw, 5.8rem); + line-height: 0.9; + letter-spacing: -0.065em; +} + +.edge-dialog p { + max-width: 31rem; + color: rgb(255 255 255 / 64%); + line-height: 1.55; +} + +.edge-dialog header button { + display: grid; + width: 2.7rem; + height: 2.7rem; + flex: 0 0 auto; + border: 1px solid rgb(255 255 255 / 16%); + border-radius: 50%; + color: white; + background: rgb(255 255 255 / 8%); + place-items: center; + font-size: 1.4rem; +} + +.dialog-facts { + display: grid; + grid-template-columns: 1fr; + gap: 0.55rem; + margin: 0; +} + +.dialog-facts > div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + border-bottom: 1px solid rgb(255 255 255 / 10%); + padding: 0.5rem 0; +} + +.dialog-facts dt { + color: rgb(255 255 255 / 42%); + font-size: 0.64rem; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.dialog-facts dd { + margin: 0; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.72rem; + text-align: right; +} + +.edge-dialog fieldset { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + border: 1px solid rgb(255 255 255 / 12%); + border-radius: 1rem; + padding: 0.8rem; +} + +.edge-dialog legend { + padding: 0 0.4rem; + color: rgb(255 255 255 / 44%); + font-size: 0.65rem; + text-transform: uppercase; +} + +.edge-dialog fieldset button { + border: 1px solid rgb(255 255 255 / 12%); + border-radius: 999px; + padding: 0.48rem 0.75rem; + color: rgb(255 255 255 / 62%); + background: transparent; +} + +.edge-dialog fieldset button.selected { + border-color: #dcff8f; + color: #132008; + background: #dcff8f; +} + +.edge-dialog footer { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin-top: auto; +} + +.edge-dialog footer small { + color: rgb(255 255 255 / 43%); +} + +@media (max-width: 760px) { + .lab-page { + justify-content: flex-end; + } + + /* + * These two pages intentionally scroll. Keeping the generic mobile + * bottom-alignment would push their overflowing content above scroll zero, + * making the first cards impossible to reach. + */ + .hub-view, + .gallery-view, + .predicate-view, + .nested-scenes-view { + justify-content: flex-start; + } + + /* + * Leave the native-style top toolbars unobstructed. When collapsed, the + * inspector is only a small status pill; expanding it still reveals the full + * diagnostics panel. + */ + .scene-debug { + top: max(4.75rem, calc(env(safe-area-inset-top) + 4rem)); + } + + .scene-debug:not([open]) { + width: auto; + } + + .lab-page h1 { + font-size: clamp(2.5rem, 12vw, 4.6rem); + } + + .hub-header, + .section-header { + align-items: flex-start; + flex-direction: column; + padding-top: 3.6rem; + } + + .hub-header h1 { + font-size: clamp(3rem, 13vw, 5.2rem); + } + + .lab-grid { + grid-template-columns: 1fr; + } + + .lab-card, + .lab-card:nth-child(1), + .lab-card:nth-child(2) { + grid-column: auto; + min-height: 12rem; + } + + .hub-footer { + flex-direction: column; + } + + .direction-view { + justify-content: flex-start; + padding-top: 7rem; + } + + .direction-compass { + position: relative; + right: auto; + bottom: auto; + align-self: center; + margin: auto 0; + transform: scale(0.86); + } + + .metric-grid, + .photo-grid { + grid-template-columns: 1fr; + } + + .photo-card { + min-height: 18rem; + } + + .photo-copy h1 { + font-size: clamp(4rem, 20vw, 7rem); + } + + .player-view { + justify-content: space-between; + } + + .album-art { + width: min(72vw, 22rem); + } + + .predicate-view { + padding-top: max(6rem, env(safe-area-inset-top)); + overflow: auto; + } + + .predicate-copy { + padding-left: 1.7rem; + padding-bottom: 4rem; + } + + .predicate-axis-note { + display: none; + } + + .edge-dialog { + width: calc(100% - 1rem); + } + + .edge-dialog h1 { + font-size: clamp(3rem, 15vw, 5rem); + } + + .nested-scenes-view { + padding-top: max(6rem, env(safe-area-inset-top)); + } + + .nested-scenes-header { + align-items: flex-start; + flex-direction: column; + padding-top: 0; + } + + .nested-case-grid { + grid-template-columns: 1fr; + } + + .nested-case > header { + min-height: auto; + } + + .nested-stage-shell { + height: min(28rem, 62vh); + } + + .nested-scenes-footer { + align-items: flex-start; + flex-direction: column; + } + + .nested-page-gesture { + display: none; + } +} + +@media (max-height: 700px) { + .album-art { + width: min(45vh, 18rem); + } + + .player-view .instance-card, + .policy-note { + display: none; + } +} diff --git a/apps/origins-demo/src/views/ChatView.vue b/apps/origins-demo/src/views/ChatView.vue new file mode 100644 index 0000000..60d600d --- /dev/null +++ b/apps/origins-demo/src/views/ChatView.vue @@ -0,0 +1,65 @@ + + + diff --git a/apps/origins-demo/src/views/DirectionLabView.vue b/apps/origins-demo/src/views/DirectionLabView.vue new file mode 100644 index 0000000..480eb1e --- /dev/null +++ b/apps/origins-demo/src/views/DirectionLabView.vue @@ -0,0 +1,116 @@ + + + diff --git a/apps/origins-demo/src/views/DirectionResultView.vue b/apps/origins-demo/src/views/DirectionResultView.vue new file mode 100644 index 0000000..fdcac5d --- /dev/null +++ b/apps/origins-demo/src/views/DirectionResultView.vue @@ -0,0 +1,87 @@ + + + diff --git a/apps/origins-demo/src/views/EdgeDialogView.vue b/apps/origins-demo/src/views/EdgeDialogView.vue new file mode 100644 index 0000000..a27b543 --- /dev/null +++ b/apps/origins-demo/src/views/EdgeDialogView.vue @@ -0,0 +1,116 @@ + + + diff --git a/apps/origins-demo/src/views/EdgePredicateView.vue b/apps/origins-demo/src/views/EdgePredicateView.vue new file mode 100644 index 0000000..d26fc3b --- /dev/null +++ b/apps/origins-demo/src/views/EdgePredicateView.vue @@ -0,0 +1,125 @@ + + + diff --git a/apps/origins-demo/src/views/FirstView.vue b/apps/origins-demo/src/views/FirstView.vue new file mode 100644 index 0000000..13ee247 --- /dev/null +++ b/apps/origins-demo/src/views/FirstView.vue @@ -0,0 +1,89 @@ + + + diff --git a/apps/origins-demo/src/views/FourthView.vue b/apps/origins-demo/src/views/FourthView.vue new file mode 100644 index 0000000..a63df88 --- /dev/null +++ b/apps/origins-demo/src/views/FourthView.vue @@ -0,0 +1,63 @@ + + + diff --git a/apps/origins-demo/src/views/GalleryView.vue b/apps/origins-demo/src/views/GalleryView.vue new file mode 100644 index 0000000..da7f6e2 --- /dev/null +++ b/apps/origins-demo/src/views/GalleryView.vue @@ -0,0 +1,88 @@ + + + diff --git a/apps/origins-demo/src/views/HubView.vue b/apps/origins-demo/src/views/HubView.vue new file mode 100644 index 0000000..9f7bc9e --- /dev/null +++ b/apps/origins-demo/src/views/HubView.vue @@ -0,0 +1,177 @@ + + + diff --git a/apps/origins-demo/src/views/NestedSceneSlide.vue b/apps/origins-demo/src/views/NestedSceneSlide.vue new file mode 100644 index 0000000..cdaf0b7 --- /dev/null +++ b/apps/origins-demo/src/views/NestedSceneSlide.vue @@ -0,0 +1,166 @@ + + + diff --git a/apps/origins-demo/src/views/NestedScenesView.vue b/apps/origins-demo/src/views/NestedScenesView.vue new file mode 100644 index 0000000..f36a81b --- /dev/null +++ b/apps/origins-demo/src/views/NestedScenesView.vue @@ -0,0 +1,275 @@ + + + diff --git a/apps/origins-demo/src/views/PhotoDetailView.vue b/apps/origins-demo/src/views/PhotoDetailView.vue new file mode 100644 index 0000000..dbcdc1c --- /dev/null +++ b/apps/origins-demo/src/views/PhotoDetailView.vue @@ -0,0 +1,114 @@ + + + diff --git a/apps/origins-demo/src/views/PlayerView.vue b/apps/origins-demo/src/views/PlayerView.vue new file mode 100644 index 0000000..17e6ded --- /dev/null +++ b/apps/origins-demo/src/views/PlayerView.vue @@ -0,0 +1,105 @@ + + + diff --git a/apps/origins-demo/src/views/SecondView.vue b/apps/origins-demo/src/views/SecondView.vue new file mode 100644 index 0000000..f3a527a --- /dev/null +++ b/apps/origins-demo/src/views/SecondView.vue @@ -0,0 +1,67 @@ + + + diff --git a/apps/origins-demo/src/views/ThirdView.vue b/apps/origins-demo/src/views/ThirdView.vue new file mode 100644 index 0000000..8c7b425 --- /dev/null +++ b/apps/origins-demo/src/views/ThirdView.vue @@ -0,0 +1,60 @@ + + + diff --git a/apps/origins-demo/tsconfig.json b/apps/origins-demo/tsconfig.json new file mode 100644 index 0000000..adbbbd1 --- /dev/null +++ b/apps/origins-demo/tsconfig.json @@ -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"] +} diff --git a/apps/origins-demo/vite.config.ts b/apps/origins-demo/vite.config.ts new file mode 100644 index 0000000..e2e4123 --- /dev/null +++ b/apps/origins-demo/vite.config.ts @@ -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, + }, +}); diff --git a/docs/routeless-origins.md b/docs/routeless-origins.md new file mode 100644 index 0000000..4323e11 --- /dev/null +++ b/docs/routeless-origins.md @@ -0,0 +1,105 @@ +# 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. + +## 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 +whole scene is destroyed. Because the same DOM survives parking, nested scroll +positions and component-local state survive without ``. diff --git a/package-lock.json b/package-lock.json index cf50f61..d7ed300 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,6 +70,21 @@ "name": "@native-vue-router/demo-electron", "version": "0.1.0" }, + "apps/origins-demo": { + "name": "@native-vue-router/origins-demo", + "version": "0.1.0", + "dependencies": { + "@native-vue-router/core-v2": "0.1.0-experimental.0", + "vue": "^3.5.39" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.7", + "typescript": "~6.0.2", + "vite": "^8.1.1", + "vite-plugin-pwa": "^1.1.0", + "vue-tsc": "^3.3.5" + } + }, "node_modules/@apideck/better-ajv-errors": { "version": "0.3.7", "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.7.tgz", @@ -2547,6 +2562,10 @@ "resolved": "packages/core", "link": true }, + "node_modules/@native-vue-router/core-v2": { + "resolved": "packages/core-v2", + "link": true + }, "node_modules/@native-vue-router/demo": { "resolved": "apps/demo", "link": true @@ -2563,6 +2582,10 @@ "resolved": "packages/electron", "link": true }, + "node_modules/@native-vue-router/origins-demo": { + "resolved": "apps/origins-demo", + "link": true + }, "node_modules/@native-vue-router/preset-native": { "resolved": "packages/preset-native", "link": true @@ -9297,6 +9320,14 @@ "vue-router": "^5.0.0" } }, + "packages/core-v2": { + "name": "@native-vue-router/core-v2", + "version": "0.1.0-experimental.0", + "license": "MIT", + "peerDependencies": { + "vue": "^3.5.0" + } + }, "packages/electron": { "name": "@native-vue-router/electron", "version": "0.1.0", diff --git a/package.json b/package.json index de048e4..209ec91 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,10 @@ "scripts": { "dev": "vite --host", "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:watch": "vitest", "test:e2e": "playwright test", diff --git a/packages/core-v2/API.md b/packages/core-v2/API.md new file mode 100644 index 0000000..ba9f6fa --- /dev/null +++ b/packages/core-v2/API.md @@ -0,0 +1,720 @@ +# Core v2 API reference + +`@native-vue-router/core-v2` is a routeless Vue scene compositor. A mounted +component can create another component, animate both through a local operation +frame, and retain either side when the operation resolves. + +This document describes the experimental `0.1.0-experimental.0` API. + +## Contents + +- [Gesture start recognition](#gesture-start-recognition) +- [Minimum setup](#minimum-setup) +- [Components](#components) +- [Nested scenes](#nested-scenes) +- [Scene and view functions](#scene-and-view-functions) +- [Actions and history](#actions-and-history) +- [Gestures](#gestures) +- [Choreographies and effects](#choreographies-and-effects) +- [Node-scoped controls](#node-scoped-controls) +- [Scene diagnostics and manual operations](#scene-diagnostics-and-manual-operations) +- [Type reference](#type-reference) +- [Errors and constraints](#errors-and-constraints) + +## Gesture start recognition + +The builder separates where a gesture begins from the direction it moves: + +```ts +const edgeBack = gesture.from + .left("clamp(24px, 7vw, 48px)") + .to.right() + .navigate((context) => (context.canGoBack ? back() : null)) + .animate(slideRight); +``` + +Edges are measured from the **gesture host element**, not unconditionally from +the browser viewport. Supported start rules are: + +```ts +gesture.from.left(distance); +gesture.from.right(distance); +gesture.from.top(distance); +gesture.from.bottom(distance); +gesture.from.anywhere(); +gesture.from.when(predicate); +``` + +`distance` accepts a number in CSS pixels or a CSS length string, including +percentages, `calc()`, and `clamp()`. It is resolved against the current host +size at pointer-down. + +`.from` is optional. A chain beginning at `.to` admits pointer-down anywhere: + +```ts +gesture.to.right(); +``` + +This is semantically equivalent to: + +```ts +gesture.from.anywhere().to.right(); +``` + +It does not capture on the first positive pixel. The recognizer waits until +directed movement crosses the intent threshold and dominates the cross-axis. + +Custom shapes, safe-area rules, and exclusion zones belong in `.from.when()`: + +```ts +const dropDialog = gesture.from + .when(({ point, bounds, event }) => { + const rail = Math.max(36, bounds.width * 0.08); + const outsideExcludedBand = + event.clientY < bounds.top + bounds.height * 0.35 || + event.clientY > bounds.top + bounds.height * 0.65; + return point.localX <= rail && outsideExcludedBand; + }) + .to.down() + .navigate(() => above(originView(DialogView))) + .animate(dropAnimation); +``` + +Interactive form controls are ignored automatically. Add +`data-origin-gesture="ignore"` to any other element or ancestor that should not +begin a gesture. + +## Minimum setup + +Import the required compositor stylesheet once: + +```ts +import "@native-vue-router/core-v2/style.css"; +``` + +Create a scene: + +```ts +import { createOriginScene, originView } from "@native-vue-router/core-v2"; +import HomeView from "./HomeView.vue"; + +export const scene = createOriginScene({ + initial: originView(HomeView, undefined, { + key: "home", + name: "Home", + }), +}); +``` + +Render it: + +```vue + + + +``` + +`OriginScene` must have a non-zero width and height through its parent layout. + +## Components + +### `OriginScene` + +Renders every currently mounted scene node as a stable, absolutely positioned +sibling. + +| Prop | Type | Required | Description | +| ------- | ------------- | -------- | -------------------------------------- | +| `scene` | `OriginScene` | yes | Scene created by `createOriginScene()` | + +The component provides node ownership to descendants, registers host elements +for measurement, and applies the scene's composited styles. Application views +must be rendered through this component before calling `useOrigin()` or +`useOriginGesture()`. + +### `OriginGesture` + +Convenience component that renders one HTML element and binds one gesture +recognizer to it. + +| Prop | Type | Default | Description | +| --------- | ------------------------- | ------- | ---------------------------------- | +| `as` | `string` | `"div"` | HTML tag used for the gesture host | +| `gesture` | `OriginGestureDefinition` | — | Preferred builder definition | + +Attributes, classes, and listeners not consumed as props are forwarded to the +rendered host. + +```vue + + ... + +``` + +For compatibility, the component also accepts the legacy mutually exclusive +set of `direction`, `edge`, `threshold`, and `action` props. + +Use `useOriginGesture()` instead when an extra wrapper is undesirable. + +### `OriginGestureSurface` + +Policy-neutral host for multiple completed builder definitions: + +```vue + + + +``` + +| Prop | Type | Default | Description | +| ---------- | ------------------------------------ | -------- | ------------------------------ | +| `as` | `string` | `"div"` | Shared native gesture host | +| `gestures` | `readonly OriginGestureDefinition[]` | required | Fully defined page-owned rules | + +The component adds no recognition or navigation policy. It installs each +definition with `useOriginGesture()`, forwards every pointer event to every +binding, and derives the least-permissive shared `touch-action`: + +- horizontal only: `pan-y`; +- vertical only: `pan-x`; +- both axes: `none`. + +Definitions should be immutable and stable for the lifetime of the rendered +surface. The owning page remains the visible declaration point for every +`.from`, `.to`, `.complete`, `.navigate`, and `.animate` choice. + +## Nested scenes + +`OriginScene` is a reusable compositor, not an application-only singleton. A +component may render another scene inside its own layout: + +```vue + +``` + +The child scene gets independent mounted nodes, retained history, measurements, +operations, and clipping. `useOrigin()` and `useOriginGesture()` resolve the +nearest scene-node provider, so declarations inside a carousel slide operate +on carousel components rather than the outer page. + +Parent/child gesture arbitration is currently pointer-down based. An eligible +child recognizer stops propagation immediately. If its later `.navigate()` +factory returns `null`, that same pointer sequence is not offered to the +parent. Cooperative nested components should reserve a start region with +`.from.when()` or `.from.left()` that allows the parent handler to receive +pointer-down. + +The nested-scenes demo contains both this cooperative policy and an intentional +greedy-child conflict. A future gesture arena could delay ownership until +direction and navigation availability are known. + +## Scene and view functions + +### `originView(component, props?, options?)` + +Creates a lightweight recipe for mounting a Vue component. + +```ts +const profile = originView( + ProfileView, + { userId: "42" }, + { key: "profile-42", name: "Profile" }, +); +``` + +The recipe is not itself a mounted instance. A forward action creates an +instance from it, then retains that exact instance while its entry remains in +history. Back does not call the recipe again. Component definitions are marked +raw so Vue does not proxy them inside reactive scene structures. + +`OriginViewOptions`: + +| Field | Type | Description | +| ------ | -------- | --------------------------------------------- | +| `key` | `string` | Recipe identity and generated node-key prefix | +| `name` | `string` | Human-readable diagnostic label | + +When no key is supplied, one is generated from the component/name and a +sequence number. + +### `createOriginScene(options)` + +Creates one independent scene graph, history context, and compositor. + +```ts +const scene = createOriginScene({ + initial: originView(HomeView), +}); +``` + +`options.initial` accepts one `OriginView` or an array of independent root +views. Scenes do not share nodes, history, operation IDs, or measurements. + +## Actions and history + +### `forward(target, choreography?, options?)` + +With choreography, creates a complete retained-history push action: + +```ts +const openProfile = () => + forward(originView(ProfileView, { userId: "42" }), slideLeft); +``` + +Without choreography, it creates an `OriginNavigationIntent` for a gesture +builder: + +```ts +.navigate(() => forward(originView(ProfileView, { userId: "42" }))) +``` + +When forward commits, the origin remains mounted but becomes parked. Its DOM, +component-local state, and nested scroll positions remain intact. + +`OriginNavigationActionOptions.placement` defaults to `"above"`. + +### `back(choreography?, options?)` + +Creates a retained-history pop action: + +```ts +const goBack = (context: OriginContext) => + context.canGoBack ? back(slideRight) : null; +``` + +Back has no target recipe. When it begins, the scene resolves the origin's +`previousNodeKey` and reveals that exact mounted instance. A committed back +unmounts only the current entry. A cancelled back hides the previous entry +again and leaves the current entry active. + +`back()` without choreography returns an animation-free navigation intent for +`.navigate()`. `back(slideRight)` returns a complete programmatic action. + +`OriginNavigationActionOptions.placement` defaults to `"under"`. + +### `originAction(target, choreography, options?)` + +Constructs a complete low-level `OriginAction`. Prefer `forward()` and `back()` +when expressing retained navigation. + +```ts +const action = originAction(profile, slideLeft, { + placement: "above", + history: "push", +}); +``` + +`OriginActionOptions`: + +| Field | Type | Default | Description | +| ----------- | -------------------- | --------- | ---------------------------- | +| `placement` | `"above" \| "under"` | `"above"` | Target stacking relationship | +| `history` | `OriginHistoryMode` | `"push"` | Target history mutation | + +### `above(target, choreography?, options?)` + +Shorthand for `originAction()` with `placement: "above"`. + +```ts +const openProfile = () => above(originView(ProfileView), slideLeft); +``` + +Placement controls stacking only. It does not imply a movement direction. +Omitting choreography returns a navigation intent for a gesture builder. + +### `under(target, choreography?, options?)` + +Shorthand for `originAction()` with `placement: "under"`. + +```ts +const goBack = (context: OriginContext) => + context.previous ? back(slideRight) : null; +``` + +`under()` does not automatically mean history back. It remains available for +custom stacking actions; `back()` is the clearer retained-history primitive. +Omitting choreography returns a navigation intent for a gesture builder. + +### History modes + +History is a linked chain of mounted scene nodes. + +| Mode | Commit behavior | +| -------- | -------------------------------------------------------------- | +| `"push"` | Park and retain the origin; activate the new target | +| `"back"` | Reuse the retained previous target; pop and unmount the origin | + +Parked entries are `inert`, `aria-hidden`, invisible, and excluded from pointer +input. They remain mounted until back pops them or the scene is destroyed. + +## Gestures + +### `gesture` + +Immutable fluent builder for component-owned gesture policy: + +```ts +const swipeBack = gesture.from + .left(32) + .to.right({ threshold: 10 }) + .complete(({ progress, velocity }) => progress >= 0.4 || velocity >= 0.9) + .navigate((context) => (context.canGoBack ? back() : null)) + .animate(slideRight); +``` + +The stages have distinct responsibilities: + +| Stage | Responsibility | +| ---------------------- | ---------------------------------------------------------- | +| `.from.*` | Optional pointer-down eligibility | +| `.to.*` | Required movement direction and intent-recognition options | +| `.complete(predicate)` | Optional release commit/cancel decision | +| `.navigate(factory)` | Required target and retained-history intent | +| `.animate(routine)` | Required source/target/frame choreography | + +The builder is persistent and immutable. Reusing an earlier stage cannot +change a definition already produced from it. + +`.to.left()`, `.to.right()`, `.to.up()`, and `.to.down()` accept optional +`OriginGestureDirectionOptions`: + +| Field | Default | Description | +| --------------- | ------- | ------------------------------------------------- | +| `threshold` | `8` | Directed CSS pixels required before capture | +| `axisDominance` | `1.15` | Directed/cross-axis ratio required before capture | + +If `.complete()` is omitted, the choreography's `commitThreshold` and +`commitVelocity` decide release normally. + +The completion context contains the origin, direction, normalized progress and +velocity, directed pixel distance, cross-axis distance, duration, pointer-up +event, host, bounds, and start/current points. Completion predicates are +synchronous because they select operation intent at release. + +### `useOriginGesture(definition)` + +Creates one primary-pointer, single-axis recognizer owned by the component that +calls it. + +```ts +const open = useOriginGesture( + gesture.to + .left({ threshold: 10 }) + .navigate(() => forward(originView(DetailsView))) + .animate(slideLeft), +); +``` + +The return value contains: + +```ts +interface OriginGestureBinding { + readonly style: Readonly; + readonly onPointerdown: (event: PointerEvent) => void; + readonly onPointermove: (event: PointerEvent) => void; + readonly onPointerup: (event: PointerEvent) => void; + readonly onPointercancel: () => void; +} +``` + +Apply all handlers to the same element. The returned style sets dimensions and +`touch-action` so native scrolling remains available on the cross-axis. + +Recognition requires: + +1. A primary, left-button pointer satisfies the optional start policy. +2. The target is not an ignored interactive element. +3. Directed movement reaches `threshold`. +4. Directed movement exceeds cross-axis movement by `axisDominance`. +5. The navigation factory returns an intent. + +Progress is directed distance divided by host width or height. Release velocity +is normalized by the same dimension. + +An asynchronous navigation factory is supported. If it resolves after the +pointer was released or cancelled, the stale result is discarded. + +The legacy `OriginGestureOptions` object remains accepted. Its `edge` is a +number inferred from the side opposite `direction`, matching the previous API. + +## Choreographies and effects + +### `defineOriginChoreography(choreography)` + +Type-safe identity helper for declaring custom visual routines. + +```ts +const scaleIn = defineOriginChoreography({ + name: "scale-in", + commitThreshold: 0.4, + commitVelocity: 0.8, + effects: ({ progress, viewport }) => ({ + source: { + transform: `scale(${1 - progress * 0.08})`, + opacity: 1 - progress * 0.3, + }, + target: { + transform: `translateY(${(1 - progress) * viewport.height}px)`, + }, + }), +}); +``` + +The function returns the same object. Its value is type checking and a clear +construction point. + +`effects()` may return: + +| Effect | Applied to | +| -------- | ---------------------------------------------------------- | +| `frame` | Source, target, and descendants on both sides of this edge | +| `source` | Component that originated this operation | +| `target` | Component created by this operation and its descendants | + +Transforms are concatenated from inherited frames to local frames. Opacity is +multiplied. Properties inside `style` use local-last precedence, except +`transform` and numeric `opacity`, which are also composed. + +Choreography callbacks should be deterministic and free of side effects. They +can run repeatedly during rendering and animation. + +### Commit thresholds + +When `finish()` does not explicitly override the decision, a target commits +when either: + +- `progress >= commitThreshold`, default `0.36`; or +- `progress >= 0.06` and `velocity >= commitVelocity`, default `0.9`. + +The operation's intent becomes final before its spring settles. + +### Included presets + +| Export | Behavior | +| ------------ | ------------------------------------------------------------- | +| `slideLeft` | Target enters from the right above a slightly receding source | +| `slideRight` | Source exits right and reveals a target underneath | +| `fade` | Source fades out as target fades in | + +These are ordinary `OriginChoreography` objects and can be replaced entirely. + +### `normalizedEffect(effect, fallbackLayer?)` + +Internal compositor helper exposed for custom diagnostics or compositors. It +returns a defined effect and adds `fallbackLayer` to the effect's own layer. +Applications normally return plain effects and let the scene normalize them. + +## Node-scoped controls + +### `useOrigin()` + +Returns controls scoped to the scene node containing the calling component. + +```ts +const origin = useOrigin(); + +await origin.perform(forward(originView(SettingsView), fade)); +``` + +Return value: + +| Field | Description | +| ----------------- | --------------------------------------------------- | +| `nodeKey` | Unique key of this mounted node | +| `scene` | Containing `OriginScene` | +| `context` | Reactive node-local `OriginContext` | +| `view` | Reactive shorthand for the current recipe | +| `previous` | Recipe belonging to the retained previous instance | +| `canGoBack` | Whether a retained previous instance exists | +| `begin(action)` | Create a target and return manual operation control | +| `perform(action)` | Create and programmatically commit a target | + +The composable throws when called outside a view mounted by `OriginScene`. +There is no global `activeView`; the injected node containing the event is the +origin. + +## Scene diagnostics and manual operations + +### `OriginScene` fields + +| Field | Type | Description | +| ------------ | ----------------------------------------- | ------------------------------------- | +| `nodes` | `ComputedRef` | Currently mounted Vue component nodes | +| `operations` | `ComputedRef` | Live operation edges | +| `roots` | `ShallowRef` | Visible operation-graph roots | + +These fields are suitable for inspectors and diagnostics. Do not mutate their +contents. + +### `scene.contextFor(nodeKey)` + +Returns the `OriginContext` for a mounted node. Throws if the key no longer +exists. + +### `scene.begin(originKey, action)` + +For forward, mounts a new target and waits one Vue tick for measurement. For +back, reveals and measures the retained previous node. It then returns an +`OriginOperationHandle`. + +```ts +const handle = await scene.begin(nodeKey, action); +handle.update(0.25, 0.4); +const committed = await handle.finish(); +``` + +Only one outgoing operation may exist for a given origin. Its created target +can immediately begin its own outgoing operation, enabling X → Y → Z chains. + +### `OriginOperationHandle` + +| Member | Description | +| ----------------------------- | --------------------------------------------------- | +| `id` | Unique operation ID | +| `originKey` | Source node key | +| `targetKey` | Created or retained target node key | +| `update(progress, velocity?)` | Update normalized interactive state | +| `finish(options?)` | Decide, settle, and return whether target committed | +| `cancel(options?)` | Force cancellation and remove the target branch | + +`OriginFinishOptions`: + +| Field | Default | Description | +| --------- | ------------------ | ---------------------------- | +| `commit` | threshold decision | Force commit or cancellation | +| `animate` | `true` | Run the settling spring | + +### `scene.perform(originKey, action)` + +Equivalent to beginning an operation and immediately finishing it with +`commit: true`. The target still uses the settling spring unless reduced motion +is active. + +### Renderer integration methods + +`registerElement()`, `registerContainer()`, `styleForNode()`, and +`isNodeInteractive()` are public at the TypeScript boundary because the Vue +renderer components consume them. They are internal integration APIs and may +change during the experimental series. + +## Type reference + +### `OriginView` + +A component recipe containing `component`, optional `props`, optional `key`, +and optional diagnostic `name`. + +### `OriginAction` + +A choreography, placement, history mode, and—except for back—target recipe. + +### `OriginNavigationIntent` + +An animation-free target, placement, and history mutation returned by +`forward()`, `back()`, `above()`, or `under()` when choreography is omitted. +Gesture `.animate()` combines it with choreography to create the internal +action. + +### Gesture definition types + +- `OriginGestureDefinition`: immutable executable result passed to + `useOriginGesture()` or the `OriginGesture` component. +- `OriginGestureStart`: anywhere, edge, or predicate start policy. +- `OriginGestureDistance`: numeric CSS pixels or a CSS length string. +- `OriginGestureStartContext`: pointer-down event, origin, host, bounds, and + local/client point. +- `OriginGestureCompletionContext`: release metrics and origin/DOM context. +- `OriginGestureDirectionOptions`: `threshold` and `axisDominance`. +- `OriginGestureBinding`: host style and four pointer handlers. +- `OriginGestureSurfaceProps`: shared host tag and completed definition list. +- `MaybeOriginNavigationIntent`: synchronous or asynchronous nullable + navigation-factory result. + +### `OriginContext` + +Node-local action context: + +- `nodeKey`: mounted origin identity. +- `view`: origin recipe. +- `canGoBack`: whether a retained previous instance exists. +- `previous`: recipe belonging to the mounted previous entry. +- `history`: recipes belonging to all retained previous entries. + +### `MaybeOriginAction` + +```ts +OriginAction | null | undefined | Promise; +``` + +### `OriginEffect` + +| Field | Description | +| ----------- | ----------------------------------------------- | +| `transform` | Composable CSS transform contribution | +| `opacity` | Multiplicative opacity contribution | +| `layer` | Relative stacking contribution | +| `style` | Other CSS properties with local-last precedence | + +`above()` adds a default target layer of `+1`; `under()` adds `-1`. + +### `OriginChoreographyContext` + +| Field | Description | +| ------------ | ------------------------------------------------------ | +| `progress` | Normalized `0..1` progress | +| `velocity` | Normalized progress units per second | +| `phase` | `preparing`, `interactive`, `settling`, or `finished` | +| `intent` | `undecided`, `commit`, or `cancel` | +| `originRect` | Origin bounds captured before target mounting | +| `targetRect` | Target bounds measured after mounting | +| `viewport` | Scene-container bounds, with browser viewport fallback | + +Rect values are viewport CSS pixels. + +### Diagnostic types + +- `OriginSceneNode`: mounted identity, retained previous key, state, recipe, + history, and incoming edge. +- `OriginSceneNodeState`: `active`, `transitioning`, or `parked`. +- `OriginOperation`: read-only live edge state. +- `OriginOperationPhase`: operation lifecycle phase. +- `OriginOperationIntent`: selected operation outcome. +- `OriginRect`: top, left, width, and height. + +### Internal types + +`OriginNodeScope` and `MutableOriginOperation` are renderer/runtime +implementation types. They are exported by the current barrel but marked +`@internal` and should not be application dependencies. + +## Errors and constraints + +- `useOrigin()` and `useOriginGesture()` must run inside a component mounted by + `OriginScene`. +- An origin can own only one outgoing operation at a time. +- Parked history entries cannot originate operations until back reveals them. +- A target can originate its own operation as soon as its incoming operation's + intent becomes commit. +- The included recognizer follows one primary pointer and one axis. +- Builder edges accept CSS lengths; arbitrary start policy belongs in + `.from.when()`. +- Every pushed history entry retains its Vue instance and DOM until a committed + back operation pops it. There is no eviction policy yet. +- Parked instances remain mounted, so their ordinary Vue effects and timers + continue running. +- A choreography creates one target. Chaining supports any number of + simultaneously mounted targets. +- Reduced-motion preference resolves settling immediately. diff --git a/packages/core-v2/README.md b/packages/core-v2/README.md new file mode 100644 index 0000000..e26b721 --- /dev/null +++ b/packages/core-v2/README.md @@ -0,0 +1,234 @@ +# Core v2: routeless origins + +`@native-vue-router/core-v2` is an experimental, Vue-only scene compositor. It +does not install Vue Router, resolve URLs, select a globally active route, or +render through `RouterView`. + +The complete function, component, option, type, gesture-edge, and choreography +reference is in [API.md](./API.md). + +The primitive is: + +> A mounted component can originate a routine that creates another component, +> moves both components relative to the origin's coordinate frame, and retains +> the previous instance until a committed back operation pops the newer entry. + +## Run the experiment + +From the workspace root: + +```sh +npm run dev:v2 +``` + +Open the printed URL to explore seven 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. + +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 +. + +## 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 + + + +``` + +Declare an interaction inside the component that should originate it: + +```vue + + + +``` + +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 + + + +``` + +## 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. + +## 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) +``` + +## Why scene nodes are flat + +The operation graph is not represented as Vue component ancestry. Every +component has one stable, keyed host directly under `OriginScene`. + +If Y were physically moved from an X→Y wrapper to the scene root when an edge +collapsed, Vue would unmount and recreate Y. Instead, v2 rewrites graph edges +and recalculates Y's effect layers while every retained VNode stays in the same +flat host. + +“Y is Z's origin” is a coordinate and retained-history relationship, not Vue +component ancestry. + +## Current experimental boundaries + +- One operation creates one target. Chaining operations already permits any + number of simultaneous scene nodes; multi-target routines are not yet + exposed as a public builder. +- A node can originate one outgoing operation at a time. Its created target + may immediately originate the next operation. +- The included pointer recognizer handles one primary pointer and one axis. + Choreographies and scene operations are independent of it. +- Multiple recognizers can share a host and arbitrate by start policy and + directional intent. A dedicated multi-pointer gesture arena is not exposed. +- Nested `OriginScene` components have independent history and measurements. + Child recognizers currently claim propagation at pointer-down, so yielding a + region to a parent requires an explicit `.from` policy. +- Every pushed history entry remains mounted until back pops it. There is not + yet an eviction policy, so applications should deliberately reset long-lived + navigation contexts when that API is introduced. +- Parked instances remain mounted and ordinary Vue timers/watchers continue to + run. Engine-specific park/resume lifecycle hooks are not exposed yet. +- Arbitrary CSS properties can be used, but only transforms and opacity have + defined multi-operation composition rules at present. + +These boundaries are explicit so the experiment can validate the origin +primitive before compatibility conveniences become permanent architecture. diff --git a/packages/core-v2/package.json b/packages/core-v2/package.json new file mode 100644 index 0000000..c1e0291 --- /dev/null +++ b/packages/core-v2/package.json @@ -0,0 +1,27 @@ +{ + "name": "@native-vue-router/core-v2", + "version": "0.1.0-experimental.0", + "type": "module", + "license": "MIT", + "files": [ + "dist", + "API.md", + "README.md" + ], + "sideEffects": [ + "./dist/style.css" + ], + "scripts": { + "build": "vite build --config vite.config.ts && vue-tsc -p tsconfig.json --emitDeclarationOnly" + }, + "peerDependencies": { + "vue": "^3.5.0" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./style.css": "./dist/style.css" + } +} diff --git a/packages/core-v2/src/components/OriginGesture.vue b/packages/core-v2/src/components/OriginGesture.vue new file mode 100644 index 0000000..9f5ce3d --- /dev/null +++ b/packages/core-v2/src/components/OriginGesture.vue @@ -0,0 +1,41 @@ + + + diff --git a/packages/core-v2/src/components/OriginGestureSurface.vue b/packages/core-v2/src/components/OriginGestureSurface.vue new file mode 100644 index 0000000..5f8c395 --- /dev/null +++ b/packages/core-v2/src/components/OriginGestureSurface.vue @@ -0,0 +1,73 @@ + + + diff --git a/packages/core-v2/src/components/OriginNodeHost.vue b/packages/core-v2/src/components/OriginNodeHost.vue new file mode 100644 index 0000000..229f489 --- /dev/null +++ b/packages/core-v2/src/components/OriginNodeHost.vue @@ -0,0 +1,52 @@ + + + diff --git a/packages/core-v2/src/components/OriginScene.vue b/packages/core-v2/src/components/OriginScene.vue new file mode 100644 index 0000000..3ec1110 --- /dev/null +++ b/packages/core-v2/src/components/OriginScene.vue @@ -0,0 +1,38 @@ + + + diff --git a/packages/core-v2/src/gesture.test.ts b/packages/core-v2/src/gesture.test.ts new file mode 100644 index 0000000..73acf70 --- /dev/null +++ b/packages/core-v2/src/gesture.test.ts @@ -0,0 +1,248 @@ +import { createApp, defineComponent, h, nextTick, type Component } from "vue"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import OriginGestureSurface from "./components/OriginGestureSurface.vue"; +import OriginScene from "./components/OriginScene.vue"; +import { gesture, useOriginGesture } from "./gesture"; +import { back, defineOriginChoreography, forward } from "./motion"; +import { createOriginScene, originView } from "./scene"; +import type { + OriginGestureBinding, + OriginGestureCompletionContext, +} from "./types"; + +const mountedApps: Array> = []; + +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, +) { + 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(forward(target, testMotion)).toMatchObject({ + target, + choreography: testMotion, + history: "push", + }); + expect(back(testMotion)).toMatchObject({ + choreography: testMotion, + history: "back", + }); + }); + + 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", + ]); + }); +}); diff --git a/packages/core-v2/src/gesture.ts b/packages/core-v2/src/gesture.ts new file mode 100644 index 0000000..6145b0a --- /dev/null +++ b/packages/core-v2/src/gesture.ts @@ -0,0 +1,582 @@ +import type { + MaybeOriginAction, + OriginAction, + OriginContext, + OriginGestureBinding, + OriginGestureBuilder, + OriginGestureCompletionContext, + OriginGestureCompletionPredicate, + OriginGestureDefinition, + OriginGestureDirection, + OriginGestureDirectionOptions, + OriginGestureDistance, + OriginGestureEdge, + OriginGestureFromBuilder, + OriginGestureFromSelection, + OriginGestureNavigationBuilder, + OriginGestureNavigationFactory, + OriginGestureOptions, + OriginGesturePoint, + OriginGestureStart, + OriginGestureStartContext, + OriginGestureStartPredicate, + OriginGestureToBuilder, + OriginOperationHandle, + OriginRect, +} from "./types"; +import { useOrigin } from "./lifecycle"; + +function ignoreGestureTarget(target: EventTarget | null) { + return ( + !(target instanceof Element) || + Boolean( + target.closest( + '[data-origin-gesture="ignore"], input, textarea, select, option, [contenteditable="true"]', + ), + ) + ); +} + +function directedDistance( + direction: OriginGestureDirection, + dx: number, + dy: number, +) { + switch (direction) { + case "left": + return -dx; + case "right": + return dx; + case "up": + return -dy; + case "down": + return dy; + } +} + +function rectOf(element: HTMLElement): OriginRect { + const rect = element.getBoundingClientRect(); + return { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + }; +} + +function pointOf( + event: Pick, + bounds: OriginRect, +): OriginGesturePoint { + return { + clientX: event.clientX, + clientY: event.clientY, + localX: event.clientX - bounds.left, + localY: event.clientY - bounds.top, + }; +} + +/** + * Resolve an arbitrary CSS length against a box with the gesture host's size. + * + * A short-lived off-screen box lets the browser handle `rem`, viewport units, + * percentages, `calc()`, and `clamp()` consistently. This runs only during + * pointer-down for edge-constrained definitions. + */ +function resolveCssDistance( + distance: OriginGestureDistance, + axis: "horizontal" | "vertical", + host: HTMLElement, + bounds: OriginRect, +) { + if (typeof distance === "number") + return Number.isFinite(distance) ? Math.max(0, distance) : 0; + + const document = host.ownerDocument; + if (!document.body) return Math.max(0, Number.parseFloat(distance) || 0); + + const container = document.createElement("div"); + const probe = document.createElement("div"); + Object.assign(container.style, { + position: "fixed", + left: "-100000px", + top: "-100000px", + width: `${bounds.width}px`, + height: `${bounds.height}px`, + visibility: "hidden", + pointerEvents: "none", + contain: "strict", + }); + Object.assign(probe.style, { + position: "absolute", + width: axis === "horizontal" ? distance : "0", + height: axis === "vertical" ? distance : "0", + }); + container.append(probe); + document.body.append(container); + const resolved = + axis === "horizontal" + ? probe.getBoundingClientRect().width + : probe.getBoundingClientRect().height; + container.remove(); + return Number.isFinite(resolved) ? Math.max(0, resolved) : 0; +} + +function matchesStart( + start: OriginGestureStart, + event: PointerEvent, + host: HTMLElement, + origin: OriginContext, + bounds: OriginRect, +) { + if (start.kind === "anywhere") return true; + const point = pointOf(event, bounds); + if (start.kind === "when") { + const context: OriginGestureStartContext = { + event, + origin, + host, + bounds, + point, + }; + return start.predicate(context); + } + + const horizontal = start.edge === "left" || start.edge === "right"; + const distance = resolveCssDistance( + start.distance, + horizontal ? "horizontal" : "vertical", + host, + bounds, + ); + switch (start.edge) { + case "left": + return point.localX >= 0 && point.localX <= distance; + case "right": + return ( + point.localX <= bounds.width && bounds.width - point.localX <= distance + ); + case "top": + return point.localY >= 0 && point.localY <= distance; + case "bottom": + return ( + point.localY <= bounds.height && + bounds.height - point.localY <= distance + ); + } +} + +function edgeStart( + edge: OriginGestureEdge, + distance: OriginGestureDistance, +): OriginGestureStart { + return Object.freeze({ kind: "edge", edge, distance }); +} + +function createNavigationBuilder( + start: OriginGestureStart, + direction: OriginGestureDirection, + recognition: Readonly, + completion: OriginGestureCompletionPredicate | undefined, + navigation: OriginGestureNavigationFactory, +): OriginGestureNavigationBuilder { + return Object.freeze({ + animate(choreography: OriginGestureDefinition["choreography"]) { + return Object.freeze({ + kind: "origin-gesture-definition", + start, + direction, + recognition, + completion, + navigation, + choreography, + }); + }, + }); +} + +function createDirectedBuilder( + start: OriginGestureStart, + direction: OriginGestureDirection, + options: OriginGestureDirectionOptions = {}, +) { + const recognition = Object.freeze({ ...options }); + const navigate = ( + navigation: OriginGestureNavigationFactory, + completion?: OriginGestureCompletionPredicate, + ) => + createNavigationBuilder( + start, + direction, + recognition, + completion, + navigation, + ); + + return Object.freeze({ + complete(completion: OriginGestureCompletionPredicate) { + return Object.freeze({ + navigate: (navigation: OriginGestureNavigationFactory) => + navigate(navigation, completion), + }); + }, + navigate, + }); +} + +function createToBuilder(start: OriginGestureStart): OriginGestureToBuilder { + return Object.freeze({ + left: (options?: OriginGestureDirectionOptions) => + createDirectedBuilder(start, "left", options), + right: (options?: OriginGestureDirectionOptions) => + createDirectedBuilder(start, "right", options), + up: (options?: OriginGestureDirectionOptions) => + createDirectedBuilder(start, "up", options), + down: (options?: OriginGestureDirectionOptions) => + createDirectedBuilder(start, "down", options), + }); +} + +function selectStart(start: OriginGestureStart): OriginGestureFromSelection { + return Object.freeze({ to: createToBuilder(Object.freeze(start)) }); +} + +const fromBuilder: OriginGestureFromBuilder = Object.freeze({ + left: (distance: OriginGestureDistance) => + selectStart(edgeStart("left", distance)), + right: (distance: OriginGestureDistance) => + selectStart(edgeStart("right", distance)), + top: (distance: OriginGestureDistance) => + selectStart(edgeStart("top", distance)), + bottom: (distance: OriginGestureDistance) => + selectStart(edgeStart("bottom", distance)), + anywhere: () => selectStart({ kind: "anywhere" }), + when: (predicate: OriginGestureStartPredicate) => + selectStart({ kind: "when", predicate }), +}); + +/** + * Root of the immutable gesture builder. + * + * `.from` is optional. Starting at `.to` admits pointer-down anywhere on the + * bound host, exactly like `.from.anywhere().to`. + * + * @example Anywhere-to-right back gesture + * ```ts + * const swipeBack = gesture + * .to.right() + * .navigate((context) => context.canGoBack ? back() : null) + * .animate(slideRight); + * ``` + * + * @example Predicate-gated gesture with custom completion + * ```ts + * const openPanel = gesture + * .from.when(({ point, bounds }) => point.localX <= bounds.width * 0.08) + * .to.down() + * .complete(({ progress, velocity }) => progress > 0.5 || velocity > 1) + * .navigate(() => above(originView(PanelView))) + * .animate(dropPanel); + * ``` + */ +export const gesture: OriginGestureBuilder = Object.freeze({ + from: fromBuilder, + to: createToBuilder(Object.freeze({ kind: "anywhere" })), +}); + +function isDefinition( + value: OriginGestureOptions | OriginGestureDefinition, +): value is OriginGestureDefinition { + return "kind" in value && value.kind === "origin-gesture-definition"; +} + +interface RuntimeGesturePolicy { + readonly direction: OriginGestureDirection; + readonly start: OriginGestureStart; + readonly threshold: number; + readonly axisDominance: number; + readonly completion?: OriginGestureCompletionPredicate; + readonly action: (context: OriginContext) => MaybeOriginAction; +} + +function legacyStart(options: OriginGestureOptions): OriginGestureStart { + if (options.edge === undefined) return { kind: "anywhere" }; + switch (options.direction) { + case "left": + return edgeStart("right", options.edge); + case "right": + return edgeStart("left", options.edge); + case "up": + return edgeStart("bottom", options.edge); + case "down": + return edgeStart("top", options.edge); + } +} + +function runtimePolicy( + options: OriginGestureOptions | OriginGestureDefinition, +): RuntimeGesturePolicy { + if (!isDefinition(options)) { + return { + direction: options.direction, + start: legacyStart(options), + threshold: options.threshold ?? 8, + axisDominance: 1.15, + action: options.action, + }; + } + + return { + direction: options.direction, + start: options.start, + threshold: options.recognition.threshold ?? 8, + axisDominance: options.recognition.axisDominance ?? 1.15, + completion: options.completion, + action: async (context) => { + const navigation = await options.navigation(context); + if (!navigation) return navigation; + const action: OriginAction = { + ...navigation, + choreography: options.choreography, + }; + return action; + }, + }; +} + +/** + * Install a component-owned pointer recognizer. + * + * Recognition is local to the element receiving these handlers. There is no + * application-wide gesture table and no lookup of a currently active view. + * + * Builder definitions may independently describe their pointer-down region + * and movement direction. Omitting `.from` recognizes pointer-down across the + * whole element. The legacy options object remains supported; its `edge` is + * inferred from the opposite side of its movement direction. + * + * Interactive controls and anything inside + * `[data-origin-gesture="ignore"]` are ignored automatically. The recognizer + * preserves native scrolling on the cross-axis through its returned style. + * + * @param definition - An immutable builder result or legacy recognizer options. + * @returns Pointer handlers and required host styles. + * @throws If called outside a component rendered by `OriginScene`. + * + * @example Builder-defined backward gesture + * ```ts + * const swipeBack = useOriginGesture( + * gesture + * .from.left("32px") + * .to.right() + * .navigate((context) => context.canGoBack ? back() : null) + * .animate(slideRight), + * ); + * ``` + */ +export function useOriginGesture( + definition: OriginGestureDefinition | OriginGestureOptions, +): OriginGestureBinding { + const origin = useOrigin(); + const policy = runtimePolicy(definition); + let pointerId = -1; + let element: HTMLElement | null = null; + let bounds: OriginRect | null = null; + let originContext: OriginContext | null = null; + let startPoint: OriginGesturePoint | null = null; + let startTime = 0; + let startX = 0; + let startY = 0; + let lastCoordinate = 0; + let lastTime = 0; + let captured = false; + let generation = 0; + let handlePromise: Promise | null = null; + let bufferedProgress = 0; + let bufferedVelocity = 0; + let bufferedDistance = 0; + let bufferedCrossDistance = 0; + + const horizontal = + policy.direction === "left" || policy.direction === "right"; + + function reset() { + pointerId = -1; + element = null; + bounds = null; + originContext = null; + startPoint = null; + captured = false; + handlePromise = null; + bufferedProgress = 0; + bufferedVelocity = 0; + bufferedDistance = 0; + bufferedCrossDistance = 0; + } + + function onPointerdown(event: PointerEvent) { + const current = event.currentTarget; + if ( + !event.isPrimary || + event.button !== 0 || + !(current instanceof HTMLElement) || + ignoreGestureTarget(event.target) + ) + return; + + const nextBounds = rectOf(current); + const nextOriginContext = origin.context.value; + if ( + !matchesStart(policy.start, event, current, nextOriginContext, nextBounds) + ) + return; + + // The component containing this declaration is the operation's origin. + event.stopPropagation(); + generation += 1; + pointerId = event.pointerId; + element = current; + bounds = nextBounds; + originContext = nextOriginContext; + startPoint = pointOf(event, nextBounds); + startTime = event.timeStamp; + startX = event.clientX; + startY = event.clientY; + lastCoordinate = horizontal ? event.clientX : event.clientY; + lastTime = event.timeStamp; + captured = false; + handlePromise = null; + } + + function updateMetrics(event: PointerEvent, release = false) { + if (!element) return; + const dx = event.clientX - startX; + const dy = event.clientY - startY; + bufferedDistance = directedDistance(policy.direction, dx, dy); + bufferedCrossDistance = horizontal ? Math.abs(dy) : Math.abs(dx); + const size = Math.max( + 1, + horizontal ? element.clientWidth : element.clientHeight, + ); + const coordinate = horizontal ? event.clientX : event.clientY; + const coordinateDelta = + policy.direction === "left" || policy.direction === "up" + ? lastCoordinate - coordinate + : coordinate - lastCoordinate; + const rawElapsed = event.timeStamp - lastTime; + const elapsed = Math.max(8, rawElapsed); + bufferedProgress = Math.max(0, Math.min(1, bufferedDistance / size)); + /* + * Pointer-up commonly repeats the final pointer-move coordinate. Preserve + * that move's flick velocity for a prompt release, but decay it when the + * pointer was held still long enough for the flick to have ended. + */ + if (!release || coordinateDelta !== 0 || rawElapsed > 80) + bufferedVelocity = (coordinateDelta * 1000) / (elapsed * size); + lastCoordinate = coordinate; + lastTime = event.timeStamp; + } + + async function onPointermove(event: PointerEvent) { + if (event.pointerId !== pointerId || !element) return; + const dx = event.clientX - startX; + const dy = event.clientY - startY; + const distance = directedDistance(policy.direction, dx, dy); + const crossDistance = horizontal ? Math.abs(dy) : Math.abs(dx); + let metricsUpdated = false; + + if (!captured) { + if ( + distance < policy.threshold || + distance < crossDistance * policy.axisDominance + ) + return; + captured = true; + element.setPointerCapture?.(pointerId); + event.preventDefault(); + updateMetrics(event); + metricsUpdated = true; + const recognitionGeneration = generation; + const action = await Promise.resolve( + policy.action(origin.context.value), + ).catch(() => null); + // An asynchronous target resolver may finish after the pointer was + // released or cancelled. It must not create an orphan scene operation. + if (recognitionGeneration !== generation || event.pointerId !== pointerId) + return; + if (!action) return reset(); + handlePromise = origin.begin(action).catch(() => null); + } + + event.preventDefault(); + if (!metricsUpdated) updateMetrics(event); + const pending = handlePromise; + const handle = pending ? await pending : null; + if (pending === handlePromise) + handle?.update(bufferedProgress, bufferedVelocity); + } + + async function onPointerup(event: PointerEvent) { + if (event.pointerId !== pointerId) return; + if (captured) updateMetrics(event, true); + + const pending = handlePromise; + const shouldFinish = captured; + const progress = bufferedProgress; + const velocity = bufferedVelocity; + const completion = + shouldFinish && + policy.completion && + element && + bounds && + originContext && + startPoint + ? policy.completion({ + origin: originContext, + direction: policy.direction, + progress, + velocity, + distance: bufferedDistance, + crossDistance: bufferedCrossDistance, + duration: Math.max(0, event.timeStamp - startTime), + event, + host: element, + bounds, + start: startPoint, + current: pointOf(event, bounds), + } satisfies OriginGestureCompletionContext) + : undefined; + generation += 1; + reset(); + + const handle = pending ? await pending : null; + if (!shouldFinish || !handle) return; + handle.update(progress, velocity); + await handle.finish( + completion === undefined ? undefined : { commit: completion }, + ); + } + + async function onPointercancel() { + generation += 1; + const pending = handlePromise; + const shouldCancel = captured; + reset(); + const handle = pending ? await pending : null; + if (shouldCancel) await handle?.cancel(); + } + + return { + style: { + // Preserve native scrolling perpendicular to the declared gesture. + touchAction: horizontal ? "pan-y" : "pan-x", + // OriginGesture is commonly the root returned by a view component. + width: "100%", + height: "100%", + }, + onPointerdown, + onPointermove: (event) => void onPointermove(event), + onPointerup: (event) => void onPointerup(event), + onPointercancel: () => void onPointercancel(), + }; +} diff --git a/packages/core-v2/src/index.ts b/packages/core-v2/src/index.ts new file mode 100644 index 0000000..e2ea5fc --- /dev/null +++ b/packages/core-v2/src/index.ts @@ -0,0 +1,38 @@ +/** + * Routeless, component-owned scene transitions and gesture recognition for Vue. + * + * The package renders flat, stable Vue component hosts and composes temporary + * origin-relative operation frames. It does not depend on Vue Router or choose + * a globally active view. + * + * @packageDocumentation + */ + +export * from "./types"; +export * from "./scene"; +export * from "./motion"; +export * from "./gesture"; +export * from "./lifecycle"; + +/** + * Convenience component that binds one `useOriginGesture()` recognizer to a + * rendered HTML element. See `OriginGestureProps` for its public props. + */ +export { default as OriginGesture } from "./components/OriginGesture.vue"; + +/** + * Policy-neutral host for multiple completed gesture definitions. The owning + * page builds each definition; this component only installs their recognizers + * and forwards pointer events across the shared surface. + */ +export { default as OriginGestureSurface } from "./components/OriginGestureSurface.vue"; + +/** + * Renderer for an `OriginScene`. Every live view is mounted as a stable, + * absolutely positioned sibling beneath this component. + */ +export { default as OriginScene } from "./components/OriginScene.vue"; + +// Makes the library build emit dist/style.css. Applications should import the +// explicit `@native-vue-router/core-v2/style.css` export as shown in the README. +import "./style.css"; diff --git a/packages/core-v2/src/lifecycle.ts b/packages/core-v2/src/lifecycle.ts new file mode 100644 index 0000000..7c64d63 --- /dev/null +++ b/packages/core-v2/src/lifecycle.ts @@ -0,0 +1,53 @@ +import { computed, inject, type InjectionKey } from "vue"; +import type { OriginNodeScope, UseOrigin } from "./types"; + +/** + * Injection key used by the internal scene-node host to establish origin + * ownership for descendant components. + * + * Application code normally calls {@link useOrigin} instead of injecting this + * key directly. + * + * @internal + */ +export const originNodeScopeKey: InjectionKey = + Symbol("origin-node-scope"); + +/** + * Access the scene from the component that owns an interaction declaration. + * + * There is deliberately no `activeView`: the injected node is the origin + * because this component is where the event or application action occurred. + * + * @returns Node-scoped scene state and operation controls. + * @throws If called outside a component rendered by `OriginScene`. + * + * @example + * ```ts + * const origin = useOrigin(); + * + * function openProfile() { + * return origin.perform( + * forward(originView(ProfileView), slideLeft), + * ); + * } + * ``` + */ +export function useOrigin(): UseOrigin { + const scope = inject(originNodeScopeKey); + if (!scope) + throw new Error("useOrigin() must be called inside an view."); + + const context = computed(() => scope.scene.contextFor(scope.nodeKey)); + + return { + nodeKey: scope.nodeKey, + scene: scope.scene, + context, + view: computed(() => context.value.view), + previous: computed(() => context.value.previous), + canGoBack: computed(() => context.value.canGoBack), + begin: (action) => scope.scene.begin(scope.nodeKey, action), + perform: (action) => scope.scene.perform(scope.nodeKey, action), + }; +} diff --git a/packages/core-v2/src/motion.ts b/packages/core-v2/src/motion.ts new file mode 100644 index 0000000..a5e76bd --- /dev/null +++ b/packages/core-v2/src/motion.ts @@ -0,0 +1,325 @@ +import type { + OriginAction, + OriginChoreography, + OriginEffect, + OriginEffectSet, + OriginHistoryMode, + OriginNavigationIntent, + OriginPlacement, + OriginView, +} from "./types"; + +/** + * Preserve type inference while declaring a custom choreography. + * + * The helper performs no runtime transformation. It gives custom routines a + * named, documented construction point and validates their shape in TypeScript. + * + * @param choreography - Side-effect-free visual effect calculator and optional + * release thresholds. + * @returns The same choreography object. + */ +export function defineOriginChoreography( + choreography: OriginChoreography, +): OriginChoreography { + return choreography; +} + +/** Options accepted by {@link originAction}. */ +export interface OriginActionOptions { + /** + * Target stacking relationship during the operation. + * + * @defaultValue `"above"` + */ + placement?: OriginPlacement; + /** + * History mutation assigned to the created target node. + * + * @defaultValue `"push"` + */ + history?: OriginHistoryMode; +} + +/** History options shared by the {@link above} and {@link under} helpers. */ +export interface OriginPlacementActionOptions { + /** + * History mutation assigned to the created target node. + * + * @defaultValue `"push"` + */ + history?: OriginHistoryMode; +} + +/** Stacking options accepted by retained-history navigation helpers. */ +export interface OriginNavigationActionOptions { + /** + * Target stacking relationship during the operation. + * + * @defaultValue `"above"` for {@link forward}, `"under"` for {@link back} + */ + placement?: OriginPlacement; +} + +function isChoreography( + value: OriginChoreography | object | undefined, +): value is OriginChoreography { + return ( + value !== undefined && + "effects" in value && + typeof value.effects === "function" + ); +} + +/** + * Create the scene mutation invoked by a click, gesture, hardware command, or + * any other application event. `above` and `under` affect stacking only; they + * do not imply a universal navigation direction. + * + * @param target - View recipe to create. + * @param choreography - Visual routine controlling the operation. + * @param options - Stacking and history behavior. + * @returns An action that can be passed to `begin()`, `perform()`, or returned + * from a gesture action factory. + */ +export function originAction( + target: OriginView, + choreography: OriginChoreography, + options: OriginActionOptions = {}, +): OriginAction { + return { + target, + choreography, + placement: options.placement ?? "above", + history: options.history ?? "push", + }; +} + +/** + * Create a retained-history push intent or complete action. + * + * Committing the action parks its origin instance and leaves it mounted until + * a later committed {@link back} action pops the new entry. Omit choreography + * when declaring `.navigate()` inside a gesture builder; provide choreography + * when passing the result directly to `begin()` or `perform()`. + * + * @param target - View recipe created if the push commits. + * @param options - Stacking options for an animation-free navigation intent. + * @returns An animation-free intent for use with a gesture builder. + */ +export function forward( + target: OriginView, + options?: OriginNavigationActionOptions, +): OriginNavigationIntent; +export function forward( + target: OriginView, + choreography: OriginChoreography, + options?: OriginNavigationActionOptions, +): OriginAction; +export function forward( + target: OriginView, + choreographyOrOptions: + OriginChoreography | OriginNavigationActionOptions = {}, + options: OriginNavigationActionOptions = {}, +): OriginAction | OriginNavigationIntent { + if (isChoreography(choreographyOrOptions)) { + return originAction(target, choreographyOrOptions, { + placement: options.placement ?? "above", + history: "push", + }); + } + return { + target, + placement: choreographyOrOptions.placement ?? "above", + history: "push", + }; +} + +/** + * Create a retained-history back intent or complete action. + * + * The action intentionally has no target recipe. At execution time the scene + * resolves the origin's mounted `previousNodeKey`, reveals that exact instance, + * and removes the current instance only if the operation commits. Omit + * choreography inside gesture `.navigate()`; provide it for a programmatic + * action. + * + * @param options - Stacking options for an animation-free navigation intent. + * @returns An animation-free back intent for use with a gesture builder. + */ +export function back( + options?: OriginNavigationActionOptions, +): OriginNavigationIntent; +export function back( + choreography: OriginChoreography, + options?: OriginNavigationActionOptions, +): OriginAction; +export function back( + choreographyOrOptions: + OriginChoreography | OriginNavigationActionOptions = {}, + options: OriginNavigationActionOptions = {}, +): OriginAction | OriginNavigationIntent { + if (!isChoreography(choreographyOrOptions)) { + return { + placement: choreographyOrOptions.placement ?? "under", + history: "back", + }; + } + return { + choreography: choreographyOrOptions, + placement: options.placement ?? "under", + history: "back", + }; +} + +/** + * Create an intent or action whose target is stacked above its origin. + * + * This helper controls stacking, not movement direction. The supplied + * choreography may move either component however the application chooses. + * Omitting choreography produces an intent for gesture `.navigate()`. + * + * @param target - View recipe to create. + * @param choreography - Visual routine controlling the operation. + * @param options - Optional history behavior. + */ +export function above( + target: OriginView, + options?: OriginPlacementActionOptions, +): OriginNavigationIntent; +export function above( + target: OriginView, + choreography: OriginChoreography, + options?: OriginPlacementActionOptions, +): OriginAction; +export function above( + target: OriginView, + choreographyOrOptions: OriginChoreography | OriginPlacementActionOptions = {}, + options: OriginPlacementActionOptions = {}, +): OriginAction | OriginNavigationIntent { + if (isChoreography(choreographyOrOptions)) { + return originAction(target, choreographyOrOptions, { + placement: "above", + history: options.history, + }); + } + return { + target, + placement: "above", + history: choreographyOrOptions.history ?? "push", + }; +} + +/** + * Create an intent or action whose target is stacked underneath its origin. + * + * Commonly used for custom reveal effects, but it has no implicit history + * meaning. Prefer {@link back} for retained-history navigation. Omitting + * choreography produces an intent for gesture `.navigate()`. + * + * @param target - View recipe to create underneath the origin. + * @param choreography - Visual routine controlling the operation. + * @param options - Optional history behavior. + */ +export function under( + target: OriginView, + options?: OriginPlacementActionOptions, +): OriginNavigationIntent; +export function under( + target: OriginView, + choreography: OriginChoreography, + options?: OriginPlacementActionOptions, +): OriginAction; +export function under( + target: OriginView, + choreographyOrOptions: OriginChoreography | OriginPlacementActionOptions = {}, + options: OriginPlacementActionOptions = {}, +): OriginAction | OriginNavigationIntent { + if (isChoreography(choreographyOrOptions)) { + return originAction(target, choreographyOrOptions, { + placement: "under", + history: options.history, + }); + } + return { + target, + placement: "under", + history: choreographyOrOptions.history ?? "push", + }; +} + +const percent = (value: number) => `${(value * 100).toFixed(4)}%`; + +/** + * Native-style forward motion: the target enters above the source while the + * source recedes slightly. These presets are examples; applications can + * replace them with arbitrary `defineOriginChoreography()` callbacks. + */ +export const slideLeft = defineOriginChoreography({ + name: "slide-left", + commitThreshold: 0.36, + commitVelocity: 0.6, + effects: ({ progress }): OriginEffectSet => ({ + source: { + transform: `translate3d(${percent(progress * -0.24)}, 0, 0) scale(${1 - progress * 0.025})`, + opacity: 1 - progress * 0.16, + }, + target: { + transform: `translate3d(${percent(1 - progress)}, 0, 0)`, + }, + }), +}); + +/** + * Back motion reveals the target underneath the source. This is merely a + * visual routine; the {@link back} action selects retained-history behavior. + */ +export const slideRight = defineOriginChoreography({ + name: "slide-right", + commitThreshold: 0.36, + commitVelocity: 0.6, + effects: ({ progress }): OriginEffectSet => ({ + source: { + transform: `translate3d(${percent(progress)}, 0, 0)`, + }, + target: { + transform: `translate3d(${percent(-0.24 + progress * 0.24)}, 0, 0) scale(${0.975 + progress * 0.025})`, + opacity: 0.84 + progress * 0.16, + }, + }), +}); + +/** + * Cross-fade preset that fades the source out while fading the target in. + * + * The default release thresholds from the scene are used. + */ +export const fade = defineOriginChoreography({ + name: "fade", + effects: ({ progress }): OriginEffectSet => ({ + source: { opacity: 1 - progress }, + target: { opacity: progress }, + }), +}); + +/** + * Normalize one contribution before the compositor combines it with effects + * inherited from earlier origin frames. + * + * Most applications should return plain effects from a choreography and let + * the scene call this function. It is exported for custom compositors and + * diagnostics. + * + * @param effect - Optional effect to normalize. + * @param fallbackLayer - Relative layer contribution added to `effect.layer`. + * @returns A defined effect with a numeric layer. + */ +export function normalizedEffect( + effect: OriginEffect | undefined, + fallbackLayer = 0, +): OriginEffect { + return { + ...effect, + layer: (effect?.layer ?? 0) + fallbackLayer, + }; +} diff --git a/packages/core-v2/src/scene.test.ts b/packages/core-v2/src/scene.test.ts new file mode 100644 index 0000000..128e445 --- /dev/null +++ b/packages/core-v2/src/scene.test.ts @@ -0,0 +1,315 @@ +import { + createApp, + defineComponent, + h, + nextTick, + onMounted, + onUnmounted, + type Component, +} from "vue"; +import { afterEach, describe, expect, it } from "vitest"; +import OriginScene from "./components/OriginScene.vue"; +import { + above, + back, + defineOriginChoreography, + forward, + under, +} from "./motion"; +import { createOriginScene, originView } from "./scene"; + +const mountedApps: Array> = []; + +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)` }, + }), +}); + +describe("origin-relative scene graph", () => { + it("composes X→Y and Y→Z as independent transform layers", async () => { + const x = originView(component("X"), undefined, { key: "x" }); + const y = originView(component("Y"), undefined, { key: "y" }); + const z = originView(component("Z"), undefined, { key: "z" }); + const scene = createOriginScene({ initial: x }); + const xKey = scene.nodes.value[0]!.key; + + const xy = await scene.begin(xKey, above(y, layeredMotion)); + xy.update(0.5); + const yz = await scene.begin(xy.targetKey, above(z, layeredMotion)); + yz.update(0.25); + + /* + * Y inherits the target half of X→Y, then adds its own source half of + * Y→Z. Z inherits X→Y as well, but receives Y→Z's target half. + */ + expect(scene.styleForNode(xy.targetKey).transform).toBe( + "translateX(50px) translateX(-25px)", + ); + expect(scene.styleForNode(xy.targetKey)).toMatchObject({ + position: "absolute", + inset: "0", + width: "100%", + height: "100%", + }); + expect(scene.styleForNode(yz.targetKey).transform).toBe( + "translateX(50px) translateX(75px)", + ); + expect(scene.nodes.value.map((node) => node.view.name)).toEqual([ + "X", + "Y", + "Z", + ]); + }); + + it("splices completed operations in either order without losing descendants", async () => { + const scene = createOriginScene({ + initial: originView(component("X"), undefined, { key: "x" }), + }); + const xKey = scene.nodes.value[0]!.key; + const xy = await scene.begin( + xKey, + above(originView(component("Y"), undefined, { key: "y" }), layeredMotion), + ); + xy.update(0.8); + const yz = await scene.begin( + xy.targetKey, + above(originView(component("Z"), undefined, { key: "z" }), layeredMotion), + ); + yz.update(0.6); + + // Completing the newer edge first parks Y while Z replaces it as the + // visual target. Y remains mounted as Z's retained previous entry. + await yz.finish({ commit: true, animate: false }); + expect(scene.nodes.value.map((node) => node.view.name)).toEqual([ + "X", + "Y", + "Z", + ]); + expect( + scene.nodes.value.find((node) => node.view.name === "Y")?.state, + ).toBe("parked"); + expect(scene.operations.value).toHaveLength(1); + expect(scene.operations.value[0]?.targetKey).toBe(yz.targetKey); + expect(scene.styleForNode(yz.targetKey).transform).toMatch( + /^translateX\(19\.9.+px\)$/, + ); + + await xy.finish({ commit: true, animate: false }); + expect( + scene.nodes.value.map((node) => [node.view.name, node.state]), + ).toEqual([ + ["X", "parked"], + ["Y", "parked"], + ["Z", "active"], + ]); + expect(scene.operations.value).toHaveLength(0); + expect(scene.styleForNode(yz.targetKey).transform).toBe("none"); + expect(scene.styleForNode(xKey).visibility).toBe("hidden"); + }); + + it("does not remount a target when its incoming operation is collapsed", async () => { + let yMounts = 0; + const X = component("X"); + const Y = defineComponent({ + name: "Y", + setup() { + onMounted(() => { + yMounts += 1; + }); + return () => h("div", "Y"); + }, + }); + const Z = component("Z"); + const scene = createOriginScene({ + initial: originView(X, undefined, { key: "x" }), + }); + const host = document.createElement("div"); + document.body.append(host); + const app = createApp({ + render: () => h(OriginScene, { scene }), + }); + mountedApps.push(app); + app.mount(host); + expect( + (host.querySelector(".nvo-scene") as HTMLElement | null)?.style.position, + ).toBe("relative"); + + const xy = await scene.begin( + scene.nodes.value[0]!.key, + above(originView(Y, undefined, { key: "y" }), layeredMotion), + ); + const yz = await scene.begin( + xy.targetKey, + above(originView(Z, undefined, { key: "z" }), layeredMotion), + ); + await nextTick(); + expect(yMounts).toBe(1); + expect( + (host.querySelector('[data-origin-view="Y"]') as HTMLElement | null) + ?.style.position, + ).toBe("absolute"); + + // Y remains the same flat, keyed host while X→Y disappears around it. + await xy.finish({ commit: true, animate: false }); + await nextTick(); + expect(yMounts).toBe(1); + expect(scene.nodes.value.map((node) => node.view.name)).toEqual([ + "X", + "Y", + "Z", + ]); + + await yz.cancel({ animate: false }); + expect( + scene.nodes.value.map((node) => [node.view.name, node.state]), + ).toEqual([ + ["X", "parked"], + ["Y", "active"], + ]); + }); + + it("reuses the retained previous node and pops only the current entry on back", async () => { + let xMounts = 0; + let xUnmounts = 0; + let yUnmounts = 0; + const X = defineComponent({ + name: "X", + setup() { + onMounted(() => { + xMounts += 1; + }); + onUnmounted(() => { + xUnmounts += 1; + }); + return () => + h( + "div", + { + "data-scroll": "feed", + style: { height: "100px", overflow: "auto" }, + }, + h("div", { style: { height: "2000px" } }, "Feed"), + ); + }, + }); + const Y = defineComponent({ + name: "Y", + setup() { + onUnmounted(() => { + yUnmounts += 1; + }); + return () => h("div", "Y"); + }, + }); + const x = originView(X, { message: "original" }, { key: "x" }); + const y = originView(Y, undefined, { key: "y" }); + const scene = createOriginScene({ initial: x }); + const host = document.createElement("div"); + document.body.append(host); + const app = createApp({ + render: () => h(OriginScene, { scene }), + }); + mountedApps.push(app); + app.mount(host); + await nextTick(); + + const xKey = scene.nodes.value[0]!.key; + const originalScroller = host.querySelector( + '[data-scroll="feed"]', + ) as HTMLElement; + originalScroller.scrollTop = 842; + + const xy = await scene.begin(xKey, forward(y, layeredMotion)); + await xy.finish({ commit: true, animate: false }); + await nextTick(); + + const yContext = scene.contextFor(xy.targetKey); + expect(yContext.previous).toBe(x); + expect(yContext.history).toEqual([x]); + expect(xMounts).toBe(1); + expect(xUnmounts).toBe(0); + expect(scene.nodes.value.find((node) => node.key === xKey)?.state).toBe( + "parked", + ); + expect(host.querySelector('[data-scroll="feed"]')).toBe(originalScroller); + expect(originalScroller.scrollTop).toBe(842); + + const yx = await scene.begin(xy.targetKey, back(layeredMotion)); + expect(yx.targetKey).toBe(xKey); + await nextTick(); + expect(host.querySelector('[data-scroll="feed"]')).toBe(originalScroller); + expect(originalScroller.scrollTop).toBe(842); + + await yx.finish({ commit: true, animate: false }); + await nextTick(); + expect(scene.nodes.value.map((node) => node.key)).toEqual([xKey]); + expect(scene.contextFor(xKey).history).toEqual([]); + expect(scene.nodes.value[0]?.state).toBe("active"); + expect(xMounts).toBe(1); + expect(xUnmounts).toBe(0); + expect(yUnmounts).toBe(1); + expect(originalScroller.scrollTop).toBe(842); + }); + + it("re-parks the retained target when a back operation is cancelled", async () => { + const x = originView(component("X"), undefined, { key: "x" }); + const y = originView(component("Y"), undefined, { key: "y" }); + const scene = createOriginScene({ initial: x }); + const xKey = scene.nodes.value[0]!.key; + const xy = await scene.begin(xKey, above(y, layeredMotion)); + await xy.finish({ commit: true, animate: false }); + + const yx = await scene.begin( + xy.targetKey, + under(x, layeredMotion, { history: "back" }), + ); + expect(yx.targetKey).toBe(xKey); + await yx.cancel({ animate: false }); + + expect(scene.operations.value).toHaveLength(0); + expect( + scene.nodes.value.map((node) => [node.view.name, node.state]), + ).toEqual([ + ["X", "parked"], + ["Y", "active"], + ]); + }); + + it("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"], + ]); + }); +}); diff --git a/packages/core-v2/src/scene.ts b/packages/core-v2/src/scene.ts new file mode 100644 index 0000000..dfddbfd --- /dev/null +++ b/packages/core-v2/src/scene.ts @@ -0,0 +1,701 @@ +import { + computed, + markRaw, + nextTick, + reactive, + shallowReactive, + shallowRef, + type Component, + type CSSProperties, +} from "vue"; +import { normalizedEffect } from "./motion"; +import type { + MutableOriginOperation, + OriginAction, + OriginChoreographyContext, + OriginContext, + OriginEffect, + OriginEffectSet, + OriginFinishOptions, + OriginOperation, + OriginOperationHandle, + OriginRect, + OriginScene, + OriginSceneNode, + OriginSceneNodeState, + OriginView, +} from "./types"; + +interface SceneNodeState { + key: string; + sequence: number; + view: OriginView; + previousNodeKey?: string; + state: OriginSceneNodeState; + incomingOperationId?: number; +} + +/** Options used to create an independent origin scene. */ +export interface CreateOriginSceneOptions { + /** + * One initial root recipe, or several independent roots rendered in the same + * flat scene. Most applications begin with one root. + */ + initial: OriginView | readonly OriginView[]; +} + +/** Optional developer-facing metadata for an {@link OriginView} recipe. */ +export interface OriginViewOptions { + /** + * Stable recipe identity used in diagnostics and generated node-key prefixes. + * It does not preserve or reuse a mounted Vue component instance. + */ + key?: string; + /** Human-readable label used by inspectors and DOM data attributes. */ + name?: string; +} + +let viewSequence = 0; + +/** + * Turn a component and props into a lightweight, reusable view recipe. + * + * Components are marked raw so Vue never attempts to proxy their definitions + * when recipes are placed in reactive scene/history structures. + * + * @typeParam Props - Props passed to the component when the recipe is mounted. + * @param component - Vue component definition to mount. + * @param props - Props captured by the recipe. + * @param options - Optional recipe identity and diagnostic name. + * @returns An immutable, reusable component recipe. It is not a Vue instance. + * + * @example + * ```ts + * const profile = originView( + * ProfileView, + * { userId: "42" }, + * { key: "profile-42", name: "Profile" }, + * ); + * ``` + */ +export function originView< + Props extends Record = Record, +>( + component: Component, + props?: Props, + options: OriginViewOptions = {}, +): OriginView { + const inferredName = + options.name ?? + (typeof component === "object" && "name" in component + ? String(component.name) + : undefined); + return markRaw({ + component: markRaw(component), + props, + key: options.key ?? `${inferredName ?? "view"}-${++viewSequence}`, + name: inferredName, + }); +} + +function clamp(value: number) { + return Math.max(0, Math.min(1, value)); +} + +function elementRect(element: HTMLElement | null | undefined) { + if (!element) return undefined; + const rect = element.getBoundingClientRect(); + return { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + }; +} + +function defaultViewport(element: HTMLElement | null): OriginRect { + return ( + elementRect(element) ?? { + top: 0, + left: 0, + width: typeof window === "undefined" ? 1 : window.innerWidth, + height: typeof window === "undefined" ? 1 : window.innerHeight, + } + ); +} + +function prefersReducedMotion() { + return ( + typeof window === "undefined" || + !window.requestAnimationFrame || + window.matchMedia?.("(prefers-reduced-motion: reduce)").matches + ); +} + +/** + * Create one independent scene/history context. + * + * The implementation stores nodes flat. Operation edges describe how their + * visual effects compose, but a Vue component never moves to a different VNode + * parent as edges are added or removed. This is what preserves component state + * while X→Y and Y→Z overlap. + * + * @param options - Initial root view recipe or recipes. + * @returns A self-contained reactive scene. Render it through `OriginScene`. + * + * @example + * ```ts + * const scene = createOriginScene({ + * initial: originView(HomeView, undefined, { key: "home" }), + * }); + * ``` + */ +export function createOriginScene( + options: CreateOriginSceneOptions, +): OriginScene { + const nodes = shallowReactive(new Map()); + const operations = shallowReactive(new Map()); + const roots = shallowRef([]); + const elements = new Map(); + let container: HTMLElement | null = null; + let nodeSequence = 0; + let operationSequence = 0; + + function uniqueNodeKey(view: OriginView) { + return `${view.key ?? view.name ?? "view"}::${++nodeSequence}`; + } + + function addNode( + view: OriginView, + previousNodeKey?: string, + incomingOperationId?: number, + ) { + const node: SceneNodeState = shallowReactive({ + key: uniqueNodeKey(view), + sequence: nodeSequence, + view: markRaw(view), + previousNodeKey, + state: incomingOperationId ? "transitioning" : "active", + incomingOperationId, + }); + nodes.set(node.key, node); + return node; + } + + const initialViews = Array.isArray(options.initial) + ? options.initial + : [options.initial]; + for (const initial of initialViews) { + const node = addNode(initial); + roots.value = [...roots.value, node.key]; + } + + function historyNodesFor(node: SceneNodeState) { + const history: SceneNodeState[] = []; + const visited = new Set([node.key]); + let previousKey = node.previousNodeKey; + while (previousKey && !visited.has(previousKey)) { + visited.add(previousKey); + const previous = nodes.get(previousKey); + if (!previous) break; + history.unshift(previous); + previousKey = previous.previousNodeKey; + } + return history; + } + + function contextFor(nodeKey: string): OriginContext { + const node = nodes.get(nodeKey); + if (!node) + throw new Error(`Origin scene node "${nodeKey}" no longer exists.`); + const historyNodes = historyNodesFor(node); + return { + nodeKey, + view: node.view, + canGoBack: Boolean(node.previousNodeKey), + previous: historyNodes.at(-1)?.view, + history: historyNodes.map((entry) => entry.view), + }; + } + + function previousNodeForAction(origin: SceneNodeState, action: OriginAction) { + switch (action.history ?? "push") { + case "push": + return origin.key; + case "back": + return origin.previousNodeKey; + } + } + + function operationContext( + operation: MutableOriginOperation, + ): OriginChoreographyContext { + return { + progress: operation.progress, + velocity: operation.velocity, + phase: operation.phase, + intent: operation.intent, + originRect: operation.originRect, + targetRect: operation.targetRect, + viewport: defaultViewport(container), + }; + } + + function effectSetFor(operation: MutableOriginOperation): OriginEffectSet { + return operation.choreography.effects(operationContext(operation)); + } + + /** + * Return the origin-frame effects inherited by a node. The source effect of + * an ancestor is intentionally excluded: descendants inherit the target side + * of an operation, while only the initiating component receives its source + * side. + */ + function inheritedEffects( + nodeKey: string, + visited = new Set(), + ): OriginEffect[] { + if (visited.has(nodeKey)) return []; + visited.add(nodeKey); + + const node = nodes.get(nodeKey); + const incoming = node?.incomingOperationId + ? operations.get(node.incomingOperationId) + : undefined; + if (!node || !incoming) return []; + + const effects = effectSetFor(incoming); + const targetLayer = incoming.placement === "above" ? 1 : -1; + return [ + ...inheritedEffects(incoming.originKey, visited), + normalizedEffect(effects.frame), + normalizedEffect(effects.target, targetLayer), + ]; + } + + function visualEffects(nodeKey: string) { + const result = inheritedEffects(nodeKey); + const outgoing = [...operations.values()].find( + (operation) => operation.originKey === nodeKey, + ); + if (!outgoing) return result; + const effects = effectSetFor(outgoing); + return [ + ...result, + normalizedEffect(effects.frame), + normalizedEffect(effects.source), + ]; + } + + /** + * Collapse independent effect layers into one host style. Geometry remains + * composable; arbitrary CSS properties use normal local-last precedence. + */ + function styleForNode(nodeKey: string): CSSProperties { + const node = nodes.get(nodeKey); + if (!node) return {}; + + const transforms: string[] = []; + let opacity = 1; + let layer = 0; + /* + * These rules are compositor invariants rather than visual theming. Keep + * them inline so a missing optional package stylesheet can never place + * scene nodes back into normal block/flex flow and vertically stack views. + */ + const style: CSSProperties = { + position: "absolute", + inset: "0", + width: "100%", + height: "100%", + overflow: "hidden", + }; + + for (const effect of visualEffects(nodeKey)) { + if (effect.transform && effect.transform !== "none") + transforms.push(effect.transform); + if (effect.opacity !== undefined) opacity *= effect.opacity; + layer += effect.layer ?? 0; + + if (effect.style) { + const effectStyle = effect.style; + Object.assign(style, effectStyle); + if ( + typeof effectStyle.transform === "string" && + effectStyle.transform !== "none" + ) + transforms.push(effectStyle.transform); + if (effectStyle.opacity !== undefined) { + const numericOpacity = Number(effectStyle.opacity); + if (Number.isFinite(numericOpacity)) opacity *= numericOpacity; + } + } + } + + // Explicitly assign the composited properties after Object.assign so an + // individual effect cannot accidentally replace inherited transform work. + style.transform = transforms.length ? transforms.join(" ") : "none"; + style.opacity = String(clamp(opacity)); + style.zIndex = 1_000_000 + layer * 10_000 + node.sequence; + style.pointerEvents = isNodeInteractive(nodeKey) ? "auto" : "none"; + if (node.state === "parked") { + // Parked entries remain mounted in their original flat hosts. Keeping + // the DOM preserves local component state and nested scroll positions, + // while visibility/inert handling removes them from presentation. + style.visibility = "hidden"; + style.contentVisibility = "hidden"; + } + return style; + } + + function outgoingFor(nodeKey: string) { + return [...operations.values()].find( + (operation) => operation.originKey === nodeKey, + ); + } + + function isNodeInteractive(nodeKey: string) { + const node = nodes.get(nodeKey); + if (!node || node.state === "parked") return false; + + // A target being cancelled is already scheduled to disappear. A source + // settling toward commit has ceded new interactions to the retained scene + // beneath/above it. This is node-local fate, not a global "active view". + const incoming = node.incomingOperationId + ? operations.get(node.incomingOperationId) + : undefined; + if (incoming?.intent === "cancel") return false; + return outgoingFor(nodeKey)?.intent !== "commit"; + } + + function updateOperation(id: number, progress: number, velocity = 0) { + const operation = operations.get(id); + if (!operation || operation.phase === "finished") return; + operation.progress = clamp(progress); + operation.velocity = velocity; + if (operation.phase === "preparing") return; + operation.phase = "interactive"; + } + + function removeNode(nodeKey: string) { + elements.delete(nodeKey); + nodes.delete(nodeKey); + roots.value = roots.value.filter((key) => key !== nodeKey); + } + + /** + * Remove a newly created forward branch while leaving the origin and all + * earlier retained entries untouched. + */ + function removeRetainedBranch(nodeKey: string, visited = new Set()) { + if (visited.has(nodeKey)) return; + visited.add(nodeKey); + + for (const node of [...nodes.values()]) { + if (node.previousNodeKey === nodeKey) + removeRetainedBranch(node.key, visited); + } + for (const operation of [...operations.values()]) { + if ( + operation.originKey === nodeKey || + operation.targetKey === nodeKey || + operation.entryTargetKey === nodeKey + ) + operations.delete(operation.id); + } + removeNode(nodeKey); + } + + function refreshVisibleState(node: SceneNodeState) { + if (node.state === "parked") return; + node.state = + node.incomingOperationId || outgoingFor(node.key) + ? "transitioning" + : "active"; + } + + /** + * Transfer an operation's visual graph position to its target without moving + * either Vue VNode. The history chain is intentionally independent from this + * temporary coordinate graph. + */ + function spliceVisualTarget(origin: SceneNodeState, target: SceneNodeState) { + const parentOperation = origin.incomingOperationId + ? operations.get(origin.incomingOperationId) + : undefined; + if (parentOperation) { + parentOperation.targetKey = target.key; + target.incomingOperationId = parentOperation.id; + } else { + roots.value = roots.value.map((key) => + key === origin.key ? target.key : key, + ); + target.incomingOperationId = undefined; + } + } + + /** + * Finalize a committed operation. + * + * Push keeps the origin mounted and parks it. Back reuses the existing + * previous node and removes only the entry being popped. + */ + function commitOperation(id: number) { + const operation = operations.get(id); + if (!operation) return false; + const origin = nodes.get(operation.originKey); + const target = nodes.get(operation.targetKey); + if (!origin || !target) return false; + + spliceVisualTarget(origin, target); + operation.phase = "finished"; + operations.delete(operation.id); + + if (operation.history === "push") { + origin.incomingOperationId = undefined; + origin.state = "parked"; + } else { + // A committed back pops only the current retained history entry. + removeNode(origin.key); + } + + target.state = "active"; + refreshVisibleState(target); + return true; + } + + function cancelOperation(id: number) { + const operation = operations.get(id); + if (!operation) return; + const origin = nodes.get(operation.originKey); + const target = nodes.get(operation.entryTargetKey); + operation.phase = "finished"; + operations.delete(id); + + if (operation.history === "back") { + if (target) { + target.incomingOperationId = undefined; + target.state = "parked"; + } + } else { + removeRetainedBranch(operation.entryTargetKey); + } + + if (origin) { + origin.state = "active"; + refreshVisibleState(origin); + } + } + + async function settle(id: number, targetProgress: 0 | 1, animate: boolean) { + const operation = operations.get(id); + if (!operation) return; + if (!animate || prefersReducedMotion()) { + operation.progress = targetProgress; + operation.velocity = 0; + return; + } + + await new Promise((resolve) => { + let position = operation.progress; + let velocity = Math.max(-8, Math.min(8, operation.velocity)); + let previous = performance.now(); + + const frame = (time: number) => { + const live = operations.get(id); + if (!live) return resolve(); + + // A damped spring makes release velocity continuous with pointer motion + // without imposing a fixed-duration easing on custom choreographies. + const elapsed = Math.min( + 0.032, + Math.max(0.001, (time - previous) / 1000), + ); + previous = time; + const iterations = Math.max(1, Math.ceil(elapsed / (1 / 120))); + const dt = elapsed / iterations; + for (let index = 0; index < iterations; index += 1) { + const acceleration = + (targetProgress - position) * 280 - velocity * 30; + velocity += acceleration * dt; + position += velocity * dt; + } + + const done = + Math.abs(targetProgress - position) < 0.002 && + Math.abs(velocity) < 0.02; + live.progress = done ? targetProgress : clamp(position); + live.velocity = done ? 0 : velocity; + if (done) resolve(); + else window.requestAnimationFrame(frame); + }; + window.requestAnimationFrame(frame); + }); + } + + async function finishOperation( + id: number, + options: OriginFinishOptions = {}, + ) { + const operation = operations.get(id); + if (!operation) return false; + + const threshold = operation.choreography.commitThreshold ?? 0.36; + // const velocityThreshold = operation.choreography.commitVelocity ?? 0.9; + const shouldCommit = + options.commit ?? + (operation.progress >= threshold || + (operation.progress >= 0.00 && + operation.velocity >= 0.3)); + // operation.velocity >= velocityThreshold)); + + // The outcome is known synchronously at release. The target can therefore + // originate another gesture while this operation is only visually settling. + operation.intent = shouldCommit ? "commit" : "cancel"; + operation.phase = "settling"; + await settle(id, shouldCommit ? 1 : 0, options.animate ?? true); + + if (!operations.has(id)) return shouldCommit; + if (shouldCommit) return commitOperation(id); + cancelOperation(id); + return false; + } + + async function begin( + originKey: string, + action: OriginAction, + ): Promise { + const origin = nodes.get(originKey); + if (!origin) + throw new Error(`Cannot animate from missing origin "${originKey}".`); + if (origin.state === "parked") + throw new Error(`Cannot animate from parked origin "${originKey}".`); + if (outgoingFor(originKey)) + throw new Error( + `Origin "${originKey}" already has an outgoing operation. ` + + "A descendant target may start its own operation instead.", + ); + + const history = action.history ?? "push"; + let target: SceneNodeState; + + if (history === "back") { + const previousKey = origin.previousNodeKey; + if (!previousKey) + throw new Error( + `Cannot go back from origin "${originKey}" without a previous entry.`, + ); + const retainedTarget = nodes.get(previousKey); + if (!retainedTarget) + throw new Error( + `Cannot go back to missing retained entry "${previousKey}".`, + ); + + /* + * If back starts while the immediately preceding forward spring is still + * settling, collapse that already-committed edge first. Creating a new + * edge back to its origin would otherwise form X→Y→X. Reciprocal + * choreographies begin at the same visual endpoints, so this handoff is + * continuous without remounting either node. + */ + const incoming = origin.incomingOperationId + ? operations.get(origin.incomingOperationId) + : undefined; + if (incoming?.originKey === retainedTarget.key) { + if (incoming.intent !== "commit") + throw new Error( + "Cannot go back through an undecided forward operation.", + ); + commitOperation(incoming.id); + } + + if (outgoingFor(retainedTarget.key)) + throw new Error( + `Retained target "${retainedTarget.key}" already has an outgoing operation.`, + ); + if (retainedTarget.incomingOperationId) + throw new Error( + `Retained target "${retainedTarget.key}" is already transitioning.`, + ); + target = retainedTarget; + } else { + if (!action.target) + throw new Error( + `A "${history}" operation requires a target view recipe.`, + ); + target = addNode(action.target, previousNodeForAction(origin, action)); + } + + const operationId = ++operationSequence; + target.incomingOperationId = operationId; + target.state = "transitioning"; + origin.state = "transitioning"; + const operation = reactive({ + id: operationId, + originKey, + targetKey: target.key, + entryTargetKey: target.key, + choreography: markRaw(action.choreography), + placement: action.placement ?? "above", + history, + progress: 0, + velocity: 0, + phase: "preparing", + intent: "undecided", + originRect: elementRect(elements.get(originKey)), + }); + operations.set(operation.id, operation); + + // Let Vue mount the target before measuring it. Gesture composables buffer + // pointer progress while this short preparation step is pending. + await nextTick(); + operation.targetRect = elementRect(elements.get(target.key)); + operation.phase = "interactive"; + + return { + id: operation.id, + originKey, + targetKey: target.key, + update: (progress, velocity) => + updateOperation(operation.id, progress, velocity), + finish: (finishOptions) => finishOperation(operation.id, finishOptions), + cancel: async (cancelOptions) => { + await finishOperation(operation.id, { + commit: false, + animate: cancelOptions?.animate, + }); + }, + }; + } + + async function perform(originKey: string, action: OriginAction) { + const operation = await begin(originKey, action); + return operation.finish({ commit: true }); + } + + return { + nodes: computed( + () => + [...nodes.values()].map((node) => ({ + ...node, + history: historyNodesFor(node).map((entry) => entry.view), + })) as readonly OriginSceneNode[], + ), + operations: computed( + () => [...operations.values()] as readonly OriginOperation[], + ), + roots, + contextFor, + begin, + perform, + registerElement(nodeKey, element) { + if (element) elements.set(nodeKey, element); + else elements.delete(nodeKey); + }, + registerContainer(element) { + container = element; + }, + styleForNode, + isNodeInteractive, + }; +} diff --git a/packages/core-v2/src/style.css b/packages/core-v2/src/style.css new file mode 100644 index 0000000..597413b --- /dev/null +++ b/packages/core-v2/src/style.css @@ -0,0 +1,37 @@ +.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; +} + +@media (prefers-reduced-motion: reduce) { + .nvo-node { + will-change: auto; + } +} diff --git a/packages/core-v2/src/types.ts b/packages/core-v2/src/types.ts new file mode 100644 index 0000000..a3868c6 --- /dev/null +++ b/packages/core-v2/src/types.ts @@ -0,0 +1,800 @@ +import type { Component, ComputedRef, CSSProperties, ShallowRef } from "vue"; + +/** + * 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 Props - The props accepted by the component recipe. + */ +export interface OriginView< + Props extends Record = Record, +> { + /** The Vue component definition that will be mounted for this recipe. */ + readonly component: Component; + /** Props passed to the component when the recipe is mounted. */ + readonly props?: Readonly; + /** + * 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; +} + +/** + * 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"; + +/** + * Controls how an operation participates in retained instance history. + * + * - `push`: create a target whose previous entry is the mounted origin. + * - `back`: reuse the mounted previous entry and pop the origin on commit. + */ +export type OriginHistoryMode = "push" | "back"; + +/** + * The target's stacking relationship to its origin while an operation exists. + */ +export type OriginPlacement = "above" | "under"; + +/** + * Visibility/lifecycle role of a mounted scene node. + * + * - `active`: currently exposed for normal interaction. + * - `transitioning`: participating in at least one live operation edge. + * - `parked`: retained in history but visually hidden and inert. + */ +export type OriginSceneNodeState = "active" | "transitioning" | "parked"; + +/** 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; +} + +/** + * 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; + /** + * 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.36` + */ + readonly commitThreshold?: number; + /** + * Normalized positive release velocity that can commit a deliberate flick + * once progress has reached at least `0.06`. + * + * @defaultValue `0.9` + */ + readonly commitVelocity?: number; +} + +/** A complete request to create and animate a target view from an origin. */ +export interface OriginAction { + /** + * Recipe for a newly created target. A back action omits this because the + * scene resolves its already-mounted previous entry. + */ + 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; +} + +/** + * 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 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; +} + +/** + * 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; + /** Live operation currently positioning this node as its target. */ + 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; + /** 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; + /** + * Set interactive progress and optional velocity. + * + * Progress is clamped to `0..1`. 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; + /** Cancel the operation and remove its target branch. */ + cancel(options?: Pick): Promise; +} + +/** + * 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; + /** Reactive snapshot of every live animation/gesture operation. */ + readonly operations: ComputedRef; + /** Keys of visible operation-graph roots; parked history is excluded. */ + readonly roots: Readonly>; + /** 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, or already owns an outgoing + * operation. + */ + begin( + originKey: string, + action: OriginAction, + ): Promise; + /** + * 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; + /** @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; +} + +/** 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; + /** Reactive shorthand for `context.value.view`. */ + readonly view: ComputedRef; + /** Reactive shorthand for `context.value.previous`. */ + readonly previous: ComputedRef; + /** Reactive shorthand for `context.value.canGoBack`. */ + readonly canGoBack: ComputedRef; + /** Begin an interactively controlled operation from this component. */ + begin(action: OriginAction): Promise; + /** Programmatically create and commit a target from this component. */ + perform(action: OriginAction): Promise; +} + +/** + * Values accepted from gesture action factories. + * + * Returning `null` or `undefined` declines the recognized gesture. A promise + * allows lazy target resolution; stale results are discarded after release or + * cancellation. + */ +export type MaybeOriginAction = + OriginAction | null | undefined | Promise; + +/** 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; + }; + +/** 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; +} + +/** 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; + +/** 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 { + /** Discriminator used by the compatibility recognizer overload. */ + 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; + /** + * 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; +} + +/** 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; +} + +/** 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; +} + +/** 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; +} + +/** Final builder stage waiting for visual choreography. */ +export interface OriginGestureNavigationBuilder { + /** Attach visual choreography and produce an executable definition. */ + animate(choreography: OriginChoreography): OriginGestureDefinition; +} + +/** 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; +} + +/** Configuration consumed by {@link useOriginGesture}. */ +export interface OriginGestureOptions { + /** + * Direction in which the pointer moves to advance the operation. + * + * The starting edge is the opposite side: `right` begins at the left edge, + * `left` at the right edge, `down` at the top, and `up` at the bottom. + */ + direction: OriginGestureDirection; + /** + * Restrict pointer-down to this many CSS pixels from the gesture host's + * starting edge. Omit it to recognize across the entire host. + * + * This is relative to the bound element, not necessarily the browser + * viewport. Use a positive number such as `24` or `36`. + */ + edge?: number; + /** + * Minimum directed movement in CSS pixels before the gesture captures. + * + * Movement must also dominate the cross-axis by a factor of `1.15`. + * + * @defaultValue `8` + */ + threshold?: number; + /** + * Resolve the scene action after directional recognition succeeds. + * + * Returning no action abandons recognition without modifying the scene. + */ + action(context: OriginContext): MaybeOriginAction; +} + +/** + * DOM bindings returned by {@link useOriginGesture}. + * + * Spread or attach all four handlers to the same `HTMLElement`. Apply + * {@link style} as well so native scrolling is preserved on the cross-axis. + */ +export interface OriginGestureBinding { + /** Required size and `touch-action` styles for the gesture host. */ + readonly style: Readonly; + /** 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; +} + +/** Props shared by both `OriginGesture` declaration styles. */ +export interface OriginGestureBaseProps { + /** + * Native HTML tag rendered as the gesture host. + * + * @defaultValue `"div"` + */ + as?: string; +} + +/** Builder-definition props accepted by the `OriginGesture` component. */ +export interface OriginGestureDefinitionProps extends OriginGestureBaseProps { + /** Immutable definition produced by the {@link gesture} builder. */ + gesture: OriginGestureDefinition; + /** Builder definitions already contain direction. */ + direction?: never; + /** Builder definitions already contain their start policy. */ + edge?: never; + /** Builder definitions already contain recognition tuning. */ + threshold?: never; + /** Builder definitions already contain their navigation factory. */ + action?: never; +} + +/** Legacy option props accepted by the `OriginGesture` component. */ +export interface OriginGestureLegacyProps extends OriginGestureBaseProps { + /** Legacy component declarations do not provide a builder definition. */ + gesture?: never; + /** Direction in which pointer movement advances operation progress. */ + direction: OriginGestureDirection; + /** + * Eligible pointer-down width in CSS pixels from the host's starting edge. + * Omit it to allow the full component surface. + */ + edge?: number; + /** + * Directed movement required before capture. + * + * @defaultValue `8` + */ + threshold?: number; + /** Node-local action factory invoked only after recognition succeeds. */ + action(context: OriginContext): MaybeOriginAction; +} + +/** + * Public props accepted by the `OriginGesture` convenience component. + * + * Prefer the builder-definition form. The legacy direction/action form remains + * available for compatibility. + */ +export type OriginGestureProps = + OriginGestureDefinitionProps | OriginGestureLegacyProps; + +/** 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. + */ + gestures: readonly OriginGestureDefinition[]; +} + +/** Public props accepted by the `OriginScene` renderer component. */ +export interface OriginSceneProps { + /** Scene instance whose flat component nodes should be rendered. */ + scene: OriginScene; +} + +/** + * 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; +} + +/** + * 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; + /** 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; +} diff --git a/packages/core-v2/tsconfig.json b/packages/core-v2/tsconfig.json new file mode 100644 index 0000000..6dd71ae --- /dev/null +++ b/packages/core-v2/tsconfig.json @@ -0,0 +1,14 @@ +{ + "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"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/core-v2/vite.config.ts b/packages/core-v2/vite.config.ts new file mode 100644 index 0000000..616a68b --- /dev/null +++ b/packages/core-v2/vite.config.ts @@ -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"] }, + }, +}); diff --git a/tsconfig.app.json b/tsconfig.app.json index 9bd245d..a7593b8 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -12,6 +12,7 @@ "paths": { "@/*": ["./apps/demo/src/*"], "@native-vue-router/core": ["./packages/core/src/index.ts"], + "@native-vue-router/core-v2": ["./packages/core-v2/src/index.ts"], "@native-vue-router/preset-native": [ "./packages/preset-native/src/index.ts" ],