Increase complexity of test demo

This commit is contained in:
2026-07-31 07:51:50 +00:00
parent 55dad11b25
commit 056f341cd3
16 changed files with 1589 additions and 52 deletions

View File

@@ -153,6 +153,57 @@ export const fall = defineOriginChoreography({
}),
});
/**
* A connected presentation rather than a full-screen route replacement.
*
* At rest the page has moved right by two thirds, leaving its left third
* visible in the exposed rail. `persistAtRest` keeps these progress-1 effects
* connected after commit instead of parking and hiding the page.
*/
export const partialDrawerOpen = defineOriginChoreography({
name: "partial-drawer-open",
persistAtRest: true,
commitThreshold: 0.28,
commitVelocity: 0.68,
effects: ({ progress }) => ({
source: {
transform: `translate3d(${percent(progress * (2 / 3))}, 0, 0)`,
style: {
filter: `brightness(${1 - progress * 0.22})`,
},
},
target: {
transform: `translate3d(${percent((progress - 1) * (2 / 3))}, 0, 0)`,
opacity: 0.35 + progress * 0.65,
style: {
boxShadow: `24px 0 70px rgb(0 0 0 / ${progress * 0.36})`,
},
},
}),
});
/** Reciprocal close motion for the connected two-thirds drawer. */
export const partialDrawerClose = defineOriginChoreography({
name: "partial-drawer-close",
commitThreshold: 0.28,
commitVelocity: 0.68,
effects: ({ progress }) => ({
source: {
transform: `translate3d(${percent(progress * (-2 / 3))}, 0, 0)`,
opacity: 1 - progress * 0.65,
style: {
boxShadow: `24px 0 70px rgb(0 0 0 / ${(1 - progress) * 0.36})`,
},
},
target: {
transform: `translate3d(${percent((1 - progress) * (2 / 3))}, 0, 0)`,
style: {
filter: `brightness(${0.78 + progress * 0.22})`,
},
},
}),
});
/**
* Reveal a full-screen overlay from above while its actual dialog remains
* anchored to the left. The gesture's custom start predicate lives in the

View File

@@ -343,6 +343,14 @@ summary:focus-visible {
--lab-glow: #438cff;
}
.lab-card--teal {
--lab-glow: #4fffd0;
}
.lab-card--orange {
--lab-glow: #ff7b42;
}
.lab-number {
color: rgb(255 255 255 / 42%);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
@@ -401,6 +409,444 @@ summary:focus-visible {
font-size: 0.7rem;
}
/* Replace checkout */
.checkout-view {
width: 100%;
height: 100%;
padding: max(1.25rem, env(safe-area-inset-top))
max(1.25rem, env(safe-area-inset-right))
max(1.25rem, env(safe-area-inset-bottom))
max(1.25rem, env(safe-area-inset-left));
overflow: auto;
background:
radial-gradient(
circle at 15% 20%,
rgb(34 101 100 / 34%),
transparent 32rem
),
linear-gradient(145deg, #07100f, #0d1518 55%, #07100f);
}
.checkout-toolbar {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
width: min(100%, 68rem);
margin: 0 auto;
color: rgb(255 255 255 / 62%);
font-size: 0.76rem;
font-weight: 720;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.checkout-toolbar button {
justify-self: start;
width: 2.5rem;
height: 2.5rem;
border: 1px solid rgb(255 255 255 / 16%);
border-radius: 50%;
background: rgb(255 255 255 / 7%);
}
.checkout-step {
justify-self: end;
}
.checkout-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(20rem, 27rem);
align-items: center;
gap: clamp(2rem, 7vw, 7rem);
width: min(100%, 68rem);
min-height: calc(100% - 3rem);
margin: 0 auto;
padding: 3rem 0;
}
.checkout-copy {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.checkout-copy h1,
.confirmation-card h1 {
margin: 0;
font-size: clamp(3.4rem, 8vw, 7rem);
line-height: 0.9;
letter-spacing: -0.065em;
}
.checkout-copy p,
.confirmation-card p {
max-width: 34rem;
margin: 0 0 0.5rem;
color: rgb(255 255 255 / 61%);
line-height: 1.6;
}
.payment-card {
display: flex;
flex-direction: column;
gap: 1rem;
border: 1px solid rgb(255 255 255 / 14%);
border-radius: 1.8rem;
padding: 1.4rem;
background: rgb(255 255 255 / 7%);
box-shadow: 0 2rem 6rem rgb(0 0 0 / 32%);
backdrop-filter: blur(18px);
}
.payment-card label {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.42rem;
color: rgb(255 255 255 / 48%);
font-size: 0.7rem;
font-weight: 720;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.payment-card input {
width: 100%;
border: 1px solid rgb(255 255 255 / 13%);
border-radius: 0.8rem;
padding: 0.8rem;
color: white;
background: rgb(0 0 0 / 18%);
}
.order-line,
.payment-row,
.card-number {
display: flex;
align-items: center;
gap: 0.75rem;
}
.order-line {
justify-content: space-between;
border-bottom: 1px solid rgb(255 255 255 / 10%);
padding-bottom: 1rem;
}
.order-line strong {
color: #7bffd4;
font-size: 1.3rem;
}
.card-number {
border: 1px solid rgb(255 255 255 / 13%);
border-radius: 0.8rem;
padding-left: 0.8rem;
color: rgb(255 255 255 / 38%);
background: rgb(0 0 0 / 18%);
}
.card-number input {
min-width: 4rem;
border: 0;
padding-left: 0;
background: transparent;
}
.pay-button,
.confirmation-action {
border: 0;
border-radius: 0.9rem;
padding: 0.9rem 1.1rem;
color: #06110d;
background: #70ffd0;
font-weight: 820;
}
.pay-button:disabled {
cursor: wait;
opacity: 0.66;
}
.payment-card > small,
.confirmation-card > small {
align-self: center;
color: rgb(255 255 255 / 40%);
}
.confirmation-view {
display: grid;
place-items: center;
}
.confirmation-card {
display: flex;
width: min(100%, 44rem);
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.confirmation-mark {
display: grid;
width: 4.5rem;
height: 4.5rem;
place-items: center;
border-radius: 50%;
color: #06110d;
background: #70ffd0;
box-shadow: 0 0 0 0.8rem rgb(112 255 208 / 9%);
font-size: 2rem;
font-weight: 900;
}
.confirmation-card code {
color: #8edfff;
}
.confirmation-card dl {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
width: 100%;
margin: 0.5rem 0;
border: 1px solid rgb(255 255 255 / 13%);
border-radius: 1rem;
overflow: hidden;
}
.confirmation-card dl > div {
padding: 0.9rem;
background: rgb(255 255 255 / 5%);
}
.confirmation-card dl > div + div {
border-left: 1px solid rgb(255 255 255 / 10%);
}
.confirmation-card dt {
color: rgb(255 255 255 / 42%);
font-size: 0.64rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.confirmation-card dd {
margin: 0.3rem 0 0;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
/* Connected partial drawer */
.partial-origin-view {
display: grid;
grid-template-columns: 1fr 2fr;
width: 100%;
height: 100%;
background: #10131a;
}
.partial-origin-rail {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2rem;
padding: max(1.5rem, env(safe-area-inset-top)) clamp(0.8rem, 3vw, 2rem)
max(1.5rem, env(safe-area-inset-bottom));
background:
radial-gradient(
circle at 20% 15%,
rgb(255 132 74 / 30%),
transparent 18rem
),
#19151a;
}
.partial-brand {
display: grid;
width: 3rem;
height: 3rem;
place-items: center;
border-radius: 0.9rem;
color: #261006;
background: #ff8c57;
font-weight: 900;
}
.partial-origin-rail nav {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.partial-origin-rail nav button {
border: 0;
border-radius: 0.7rem;
padding: 0.75rem;
color: rgb(255 255 255 / 62%);
text-align: left;
background: transparent;
}
.partial-origin-rail nav button:first-child {
color: white;
background: rgb(255 255 255 / 9%);
}
.partial-origin-rail .instance-card {
grid-template-columns: minmax(0, 1fr);
width: 100%;
min-width: 0;
margin-top: auto;
}
.partial-origin-rail .instance-card strong {
grid-row: auto;
grid-column: auto;
}
.partial-origin-content {
display: flex;
min-width: 0;
flex-direction: column;
padding: max(1.5rem, env(safe-area-inset-top))
max(1.5rem, env(safe-area-inset-right))
max(1.5rem, env(safe-area-inset-bottom)) clamp(1.5rem, 5vw, 5rem);
background:
linear-gradient(rgb(255 255 255 / 3%) 1px, transparent 1px),
linear-gradient(90deg, rgb(255 255 255 / 3%) 1px, transparent 1px), #0b0e14;
background-size: 48px 48px;
}
.partial-origin-content > header,
.partial-drawer-panel > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.partial-origin-content > header button,
.partial-drawer-panel > header button {
border: 1px solid rgb(255 255 255 / 14%);
border-radius: 999px;
padding: 0.55rem 0.8rem;
color: white;
background: rgb(255 255 255 / 7%);
}
.partial-origin-content > header span {
color: rgb(255 255 255 / 38%);
font-size: 0.66rem;
font-weight: 750;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.partial-origin-copy {
display: flex;
max-width: 48rem;
flex: 1;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 1rem;
}
.partial-origin-copy h1,
.partial-drawer-panel h1 {
margin: 0;
font-size: clamp(3.5rem, 8vw, 7rem);
line-height: 0.9;
letter-spacing: -0.065em;
}
.partial-origin-copy p,
.partial-drawer-panel > p {
max-width: 38rem;
margin: 0;
color: rgb(255 255 255 / 62%);
line-height: 1.6;
}
.partial-drawer-layer {
display: grid;
grid-template-columns: 2fr 1fr;
width: 100%;
height: 100%;
}
.partial-drawer-panel {
z-index: 1;
display: flex;
min-width: 0;
flex-direction: column;
gap: 1.4rem;
padding: max(1.5rem, env(safe-area-inset-top)) clamp(1.2rem, 5vw, 4rem)
max(1.5rem, env(safe-area-inset-bottom))
max(1.2rem, env(safe-area-inset-left));
background:
radial-gradient(
circle at 12% 12%,
rgb(255 141 88 / 28%),
transparent 25rem
),
#18151b;
box-shadow: 24px 0 80px rgb(0 0 0 / 35%);
}
.partial-drawer-panel > header > div {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.partial-drawer-panel > header button {
width: 2.8rem;
height: 2.8rem;
padding: 0;
font-size: 1.5rem;
}
.partial-drawer-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: auto 0;
}
.partial-drawer-list button {
display: flex;
align-items: center;
gap: 1rem;
border: 1px solid rgb(255 255 255 / 10%);
border-radius: 1rem;
padding: 1rem;
color: white;
text-align: left;
background: rgb(255 255 255 / 5%);
}
.partial-drawer-list button span {
color: #ff9b6c;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.7rem;
}
.partial-drawer-panel > small {
color: rgb(255 255 255 / 38%);
}
.partial-drawer-backdrop {
width: 100%;
height: 100%;
border: 0;
padding: 0;
background: rgb(0 0 0 / 18%);
cursor: w-resize;
}
/* Scene inspector */
.scene-debug {
@@ -501,6 +947,10 @@ summary:focus-visible {
color: #8edfff;
}
.node-state--exposed {
color: #ffad7f;
}
.debug-content code {
color: #8edfff;
}
@@ -1949,6 +2399,78 @@ summary:focus-visible {
.nested-page-gesture {
display: none;
}
.checkout-layout {
grid-template-columns: 1fr;
align-items: start;
padding-top: 4rem;
}
.checkout-copy h1,
.confirmation-card h1 {
font-size: clamp(3.2rem, 16vw, 5.4rem);
}
.checkout-step {
display: none;
}
.checkout-toolbar {
grid-template-columns: 1fr auto;
}
.confirmation-card dl {
grid-template-columns: 1fr;
}
.confirmation-card dl > div + div {
border-top: 1px solid rgb(255 255 255 / 10%);
border-left: 0;
}
.partial-origin-rail {
gap: 1rem;
padding-right: 0.6rem;
padding-left: 0.6rem;
}
.partial-origin-rail nav button {
padding: 0.55rem 0.35rem;
font-size: 0.66rem;
}
.partial-origin-rail .instance-card {
display: none;
}
.partial-origin-content {
padding-left: 1.25rem;
}
.partial-origin-copy h1,
.partial-drawer-panel h1 {
font-size: clamp(2.8rem, 13vw, 4.8rem);
}
.partial-drawer-panel {
padding-right: 1rem;
padding-left: 1rem;
}
.partial-drawer-panel > header {
position: relative;
padding-right: 2.8rem;
}
.partial-drawer-panel > header button {
position: absolute;
top: 0;
right: 0;
}
.partial-drawer-panel h1 {
font-size: clamp(2.15rem, 10vw, 3.25rem);
}
}
@media (max-height: 700px) {

View File

@@ -31,13 +31,11 @@ const createSecond = () => above(secondView(), slideLeft);
const goBack = (context: OriginContext) =>
context.canGoBack ? back(slideRight) : null;
const forwardGesture = gesture.to
.left()
const forwardGesture = gesture
.to.left()
.navigate(() => above(secondView()))
.animate(slideLeft);
const backGesture = gesture
// .from.left("clamp(24px, 8%, 64px)")
// .from.left("20%")
.to.right()
.navigate((context) => (context.canGoBack ? back() : null))
.animate(slideRight);

View File

@@ -17,6 +17,8 @@ import EdgePredicateView from "./EdgePredicateView.vue";
import FirstView from "./FirstView.vue";
import GalleryView from "./GalleryView.vue";
import NestedScenesView from "./NestedScenesView.vue";
import PaymentDetailsView from "./PaymentDetailsView.vue";
import PartialDrawerPageView from "./PartialDrawerPageView.vue";
import PlayerView from "./PlayerView.vue";
const origin = useOrigin();
@@ -60,6 +62,10 @@ const openPredicate = () =>
actionFor(EdgePredicateView, "Predicate Edge", focusPortal);
const openNestedScenes = () =>
actionFor(NestedScenesView, "Nested Scenes", focusPortal);
const openCheckout = () =>
actionFor(PaymentDetailsView, "Payment Details", slideLeft);
const openPartialDrawer = () =>
actionFor(PartialDrawerPageView, "Partial Drawer", focusPortal);
const labs = [
{
@@ -123,6 +129,24 @@ const labs = [
action: openNestedScenes,
accent: "blue",
},
{
number: "08",
title: "Replace checkout",
description:
"Confirm a mock payment, replace its mounted entry, then test where back returns.",
tags: ["atomic replace", "history rewrite"],
action: openCheckout,
accent: "teal",
},
{
number: "09",
title: "Partial drawer",
description:
"Push a connected drawer two-thirds across while the live source occupies the final third.",
tags: ["persistent effects", "partial view"],
action: openPartialDrawer,
accent: "orange",
},
] as const;
</script>

View File

@@ -0,0 +1,74 @@
<script setup lang="ts">
import {
OriginGesture,
back,
gesture,
slideRight,
useOrigin,
} from "@native-vue-router/core-v2";
import InstanceCard from "../components/InstanceCard.vue";
import { useDemoInstance } from "../lab-state";
defineProps<{
orderReference: string;
replacedInstance: string;
}>();
const origin = useOrigin();
const identity = useDemoInstance("Confirmation");
const returnGesture = gesture.from
.left("clamp(24px, 8%, 64px)")
.to.right()
.navigate((context) => (context.canGoBack ? back() : null))
.animate(slideRight);
function returnToLabs() {
if (origin.context.value.canGoBack) void origin.perform(back(slideRight));
}
</script>
<template>
<OriginGesture
class="checkout-view confirmation-view"
:gesture="returnGesture"
>
<section class="confirmation-card">
<div class="confirmation-mark" aria-hidden="true"></div>
<div class="eyebrow">Payment complete</div>
<h1>Order confirmed.</h1>
<p>
The Payment Details node <code>{{ replacedInstance }}</code> has been
removed. Back now resolves the retained Hub instance directly.
</p>
<dl>
<div>
<dt>Order</dt>
<dd>{{ orderReference }}</dd>
</div>
<div>
<dt>History depth</dt>
<dd>{{ origin.context.value.history.length }}</dd>
</div>
<div>
<dt>Previous view</dt>
<dd>{{ origin.context.value.previous?.name ?? "None" }}</dd>
</div>
</dl>
<InstanceCard
label="Confirmation instance"
:instance="identity.instance"
:status="identity.status.value"
/>
<button
class="confirmation-action"
type="button"
data-origin-gesture="ignore"
@click="returnToLabs"
>
Back to labs
</button>
<small>Try the left-edge back gesture too.</small>
</section>
</OriginGesture>
</template>

View File

@@ -0,0 +1,99 @@
<script setup lang="ts">
import {
OriginGestureSurface,
back,
forward,
gesture,
originView,
slideRight,
useOrigin,
} from "@native-vue-router/core-v2";
import InstanceCard from "../components/InstanceCard.vue";
import { useDemoInstance } from "../lab-state";
import { partialDrawerOpen } from "../motions";
import PartialDrawerView from "./PartialDrawerView.vue";
const origin = useOrigin();
const identity = useDemoInstance("Drawer page");
const drawerView = () =>
originView(
PartialDrawerView,
{ openedFrom: identity.instance },
{ key: "partial-drawer", name: "Two-thirds Drawer" },
);
const openDrawerGesture = gesture.from
.left("clamp(32px, 10%, 88px)")
.to.right()
.navigate(() => forward(drawerView()))
.animate(partialDrawerOpen);
const gestures = [openDrawerGesture] as const;
function openDrawer() {
void origin.perform(forward(drawerView(), partialDrawerOpen));
}
function returnToLabs() {
if (origin.context.value.canGoBack) void origin.perform(back(slideRight));
}
</script>
<template>
<OriginGestureSurface
as="main"
class="partial-origin-view"
:gestures="gestures"
>
<!--
This rail is the left third of the full page. Once the whole page moves
right by 2/3, this exact live DOM remains visible in the final third.
-->
<aside class="partial-origin-rail">
<div class="partial-brand">OV</div>
<nav aria-label="Example navigation">
<button type="button" data-origin-gesture="ignore">Inbox</button>
<button type="button" data-origin-gesture="ignore">Projects</button>
<button type="button" data-origin-gesture="ignore">Archive</button>
</nav>
<InstanceCard
label="Page instance"
:instance="identity.instance"
:status="identity.status.value"
/>
</aside>
<section class="partial-origin-content">
<header>
<button
type="button"
data-origin-gesture="ignore"
aria-label="Return to labs"
@click="returnToLabs"
>
Labs
</button>
<span>Connected presentation</span>
</header>
<div class="partial-origin-copy">
<div class="eyebrow">Partial route lab</div>
<h1>Keep one third of this page on screen.</h1>
<p>
Drag right from the left edge. The drawer becomes a real history
entry, while this same mounted page remains translated and visible.
</p>
<button
class="primary-action"
type="button"
data-origin-gesture="ignore"
@click="openDrawer"
>
Open two-thirds drawer
</button>
<div class="gesture-hint"><span></span> Pull from the left edge</div>
</div>
</section>
</OriginGestureSurface>
</template>

View File

@@ -0,0 +1,84 @@
<script setup lang="ts">
import {
OriginGesture,
back,
gesture,
useOrigin,
} from "@native-vue-router/core-v2";
import InstanceCard from "../components/InstanceCard.vue";
import { useDemoInstance } from "../lab-state";
import { partialDrawerClose } from "../motions";
defineProps<{ openedFrom: string }>();
const origin = useOrigin();
const identity = useDemoInstance("Drawer");
const closeGesture = gesture.to
.left()
.navigate((context) => (context.canGoBack ? back() : null))
.animate(partialDrawerClose);
function closeDrawer() {
if (origin.context.value.canGoBack)
void origin.perform(back(partialDrawerClose));
}
</script>
<template>
<OriginGesture class="partial-drawer-layer" :gesture="closeGesture">
<aside class="partial-drawer-panel">
<header>
<div>
<div class="eyebrow">Two-thirds destination</div>
<h1>Workspace</h1>
</div>
<button
type="button"
data-origin-gesture="ignore"
aria-label="Close drawer"
@click="closeDrawer"
>
×
</button>
</header>
<p>
This drawer is its own Vue view and history entry. It did not copy,
teleport, or remount the page visible beside it.
</p>
<div class="partial-drawer-list">
<button type="button" data-origin-gesture="ignore">
<span>01</span>
Recent activity
</button>
<button type="button" data-origin-gesture="ignore">
<span>02</span>
Shared with me
</button>
<button type="button" data-origin-gesture="ignore">
<span>03</span>
Offline files
</button>
</div>
<InstanceCard
label="Drawer instance"
:instance="identity.instance"
:status="identity.status.value"
/>
<small>
Opened over {{ openedFrom }} · swipe left or tap the exposed third to
close.
</small>
</aside>
<button
class="partial-drawer-backdrop"
type="button"
aria-label="Close drawer from exposed page"
@click="closeDrawer"
/>
</OriginGesture>
</template>

View File

@@ -0,0 +1,156 @@
<script setup lang="ts">
import { ref } from "vue";
import {
OriginGestureSurface,
back,
gesture,
originView,
replace,
slideLeft,
slideRight,
useOrigin,
} from "@native-vue-router/core-v2";
import InstanceCard from "../components/InstanceCard.vue";
import { useDemoInstance } from "../lab-state";
import OrderConfirmationView from "./OrderConfirmationView.vue";
const origin = useOrigin();
const identity = useDemoInstance("Payment");
const cardholder = ref("Ada Lovelace");
const lastFour = ref("4242");
const processing = ref(false);
function confirmationView() {
return originView(
OrderConfirmationView,
{
orderReference: `NVO-${Math.floor(Date.now() / 1000)
.toString()
.slice(-6)}`,
replacedInstance: identity.instance,
},
{ key: "order-confirmation", name: "Order Confirmation" },
);
}
const confirmGesture = gesture.to
.left()
.navigate(() => replace(confirmationView()))
.animate(slideLeft);
const backGesture = gesture.from
.left("clamp(24px, 8%, 64px)")
.to.right()
.navigate((context) => (context.canGoBack ? back() : null))
.animate(slideRight);
const gestures = [confirmGesture, backGesture] as const;
function returnToLabs() {
if (origin.context.value.canGoBack) void origin.perform(back(slideRight));
}
async function pay() {
if (processing.value) return;
processing.value = true;
// `replace` is one atomic scene operation. Payment Details stays mounted
// throughout the animation and is removed only after the commit completes.
await origin.perform(replace(confirmationView(), slideLeft));
}
</script>
<template>
<OriginGestureSurface
as="main"
class="checkout-view payment-view"
:gestures="gestures"
>
<header class="checkout-toolbar">
<button
type="button"
data-origin-gesture="ignore"
aria-label="Return to labs"
@click="returnToLabs"
>
</button>
<span>Secure checkout</span>
<span class="checkout-step">Step 2 of 2</span>
</header>
<section class="checkout-layout">
<div class="checkout-copy">
<div class="eyebrow">Replace-history lab</div>
<h1>Payment details</h1>
<p>
Completing this mock payment replaces this mounted entry. The
confirmation page will point straight back to the Hub.
</p>
<InstanceCard
label="Payment instance"
:instance="identity.instance"
:status="identity.status.value"
/>
</div>
<form class="payment-card" @submit.prevent="pay">
<div class="order-line">
<span>Origin Pro</span>
<strong>$48.00</strong>
</div>
<label>
Cardholder
<input
v-model="cardholder"
data-origin-gesture="ignore"
autocomplete="cc-name"
/>
</label>
<label>
Card number
<span class="card-number">
<b> </b>
<input
v-model="lastFour"
data-origin-gesture="ignore"
inputmode="numeric"
maxlength="4"
autocomplete="cc-number"
/>
</span>
</label>
<div class="payment-row">
<label>
Expiry
<input
data-origin-gesture="ignore"
value="08 / 29"
autocomplete="cc-exp"
/>
</label>
<label>
CVC
<input
data-origin-gesture="ignore"
value="123"
maxlength="4"
autocomplete="cc-csc"
/>
</label>
</div>
<button
class="pay-button"
type="submit"
data-origin-gesture="ignore"
:disabled="processing"
>
{{ processing ? "Confirming…" : "Pay $48.00" }}
</button>
<small>Or swipe left anywhere outside the form controls.</small>
</form>
</section>
</OriginGestureSurface>
</template>