The previous round regressed the demo against main: dropped frames while a finger drags, and swipes ignored when they start imperfectly. Frame drops: hosts used touch-action: manipulation plus a blocking touchmove listener, so Chrome waited for JavaScript on every touch move (~30% of frames over 20ms in a drag, p95 28ms). Hosts again use per-axis touch-action like main (pan-y for horizontal gestures, and so on), and attach no touch listener. Flaky recognition: an irreversible decision after 6px dropped swipes with a wobbly or backward start. Recognition again measures from the pointer-down point until the pointer lifts, as on main. Scrolling beside gestures, natively: - .from.scrollEdge() uses direction-specific touch-action while its scroller is at the edge (pan-x pan-down for drag-down at the top), the pull-to-refresh pattern from the Pointer Events spec. A passive scroll listener keeps it current; no touch handling blocks the browser. - Where directional values are unsupported (Safari), scroll-edge gestures alone fall back to claiming touches at the browser's first cancelable touchmove. - Hosts publish touch-action as --nvo-touch-action and the stylesheet applies it to descendants, so nested scroll containers keep it and horizontal swipes over lists are not cancelled. Ignored subtrees get touch-action: auto back. - Bindings gain listeners and ref (spread on the host); the components use them. Feel tests (npm run test:feel): Chromium with emulated touch checks recognition on ten human-like traces, frame pacing budgets while dragging (p95 <= 20ms, <= 5% over 20ms) for a swipe and a scroll-edge dismiss, and the behaviour checks. Against the previous code they fail on exactly the reported regressions; now 23/23 pass. On the demo, all traces match main and drag frame pacing matches or beats it. Commit thresholds are unchanged (0.25 distance, 0.2 flick). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
142 lines
4.1 KiB
TypeScript
142 lines
4.1 KiB
TypeScript
import { createApp, defineComponent, h, ref } from "vue";
|
|
import "../../../src/style.css";
|
|
import {
|
|
OriginGesture,
|
|
OriginGestureSurface,
|
|
OriginScene,
|
|
back,
|
|
createOriginScene,
|
|
defineOriginChoreography,
|
|
forward,
|
|
gesture,
|
|
originView,
|
|
slideLeft,
|
|
useOrigin,
|
|
} from "../../../src";
|
|
|
|
declare global {
|
|
interface Window {
|
|
log: string[];
|
|
inPointerup: boolean;
|
|
scene: ReturnType<typeof createOriginScene>;
|
|
scrollTopOf: (selector: string) => number;
|
|
begin: (name: "matched" | "mismatched") => Promise<void>;
|
|
}
|
|
}
|
|
window.log = [];
|
|
window.inPointerup = false;
|
|
window.addEventListener("pointerup", () => (window.inPointerup = true), true);
|
|
window.addEventListener("pointerup", () =>
|
|
setTimeout(() => (window.inPointerup = false)),
|
|
);
|
|
const warn = console.warn;
|
|
console.warn = (...args: unknown[]) => {
|
|
window.log.push("warn: " + String(args[0]));
|
|
warn(...args);
|
|
};
|
|
|
|
const rows = (n: number) =>
|
|
Array.from({ length: n }, (_, i) => h("div", { class: "row" }, `row ${i}`));
|
|
|
|
const Detail = defineComponent({
|
|
name: "Detail",
|
|
setup() {
|
|
const list = ref<HTMLElement | null>(null);
|
|
const dismiss = gesture.from
|
|
.scrollEdge(list, "top")
|
|
.to.down()
|
|
.navigate((c) => (c.canGoBack ? back() : null))
|
|
.onCommit(() => window.log.push("dismiss committed"))
|
|
.animate(
|
|
defineOriginChoreography({
|
|
name: "dismiss",
|
|
effects: ({ progress }) => ({
|
|
source: { transform: `translateY(${progress * 100}%)` },
|
|
}),
|
|
}),
|
|
);
|
|
return () =>
|
|
h(OriginGesture, { gesture: dismiss, class: "page", id: "detail" }, () =>
|
|
h("div", { class: "list", id: "detail-list", ref: list }, rows(40)),
|
|
);
|
|
},
|
|
});
|
|
|
|
const Home = defineComponent({
|
|
name: "Home",
|
|
setup() {
|
|
const open = gesture.to
|
|
.left()
|
|
.navigate(() => forward(originView(Detail, undefined, { key: "detail" })))
|
|
.onCommit(() =>
|
|
window.log.push(`open committed inPointerup=${window.inPointerup}`),
|
|
)
|
|
.animate(slideLeft);
|
|
return () =>
|
|
h(
|
|
OriginGestureSurface,
|
|
{ gestures: [open], class: "page", id: "home" },
|
|
() => h("div", { class: "list", id: "home-list" }, rows(40)),
|
|
);
|
|
},
|
|
});
|
|
|
|
// Continuity check pair: a drawer that rests at 80% and a close that starts
|
|
// either from the same pose (matched) or from 0 (mismatched).
|
|
const drawerOpen = defineOriginChoreography({
|
|
name: "drawer-open",
|
|
persistAtRest: true,
|
|
effects: ({ progress }) => ({
|
|
source: { transform: `translateX(${progress * 80}%)` },
|
|
}),
|
|
});
|
|
const closes = {
|
|
matched: defineOriginChoreography({
|
|
name: "drawer-close-matched",
|
|
effects: ({ progress }) => ({
|
|
target: { transform: `translateX(${(1 - progress) * 80}%)` },
|
|
}),
|
|
}),
|
|
mismatched: defineOriginChoreography({
|
|
name: "drawer-close-mismatched",
|
|
effects: ({ progress }) => ({
|
|
target: { transform: `translateX(${(1 - progress) * 50}%)` },
|
|
}),
|
|
}),
|
|
};
|
|
const Drawer = defineComponent({
|
|
name: "Drawer",
|
|
render: () => h("div", { class: "page" }, "drawer"),
|
|
});
|
|
const Plain = defineComponent({
|
|
name: "Plain",
|
|
setup() {
|
|
const origin = useOrigin();
|
|
(window as unknown as { openDrawer: () => Promise<boolean> }).openDrawer =
|
|
() =>
|
|
origin.perform(
|
|
forward(originView(Drawer, undefined, { key: "drawer" }), drawerOpen),
|
|
);
|
|
return () => h("div", { class: "page", id: "plain" }, "plain");
|
|
},
|
|
});
|
|
|
|
const params = new URLSearchParams(location.search);
|
|
const initial = params.get("view") === "plain" ? Plain : Home;
|
|
const scene = createOriginScene({
|
|
initial: originView(initial, undefined, { key: "root" }),
|
|
});
|
|
window.scene = scene;
|
|
window.scrollTopOf = (selector) =>
|
|
document.querySelector(selector)?.scrollTop ?? -1;
|
|
window.begin = async (name) => {
|
|
await (
|
|
window as unknown as { openDrawer: () => Promise<boolean> }
|
|
).openDrawer();
|
|
const drawerKey = scene.nodes.value.find((n) => n.view.key === "drawer")!.key;
|
|
const handle = await scene.begin(drawerKey, back(closes[name]));
|
|
await handle.cancel({ animate: false });
|
|
await handle.finish({ commit: false, animate: false });
|
|
};
|
|
createApp({ render: () => h(OriginScene, { scene }) }).mount("#app");
|