Restore native scrolling and reliable recognition; add feel tests
The previous round regressed the demo against main: dropped frames while a finger drags, and swipes ignored when they start imperfectly. Frame drops: hosts used touch-action: manipulation plus a blocking touchmove listener, so Chrome waited for JavaScript on every touch move (~30% of frames over 20ms in a drag, p95 28ms). Hosts again use per-axis touch-action like main (pan-y for horizontal gestures, and so on), and attach no touch listener. Flaky recognition: an irreversible decision after 6px dropped swipes with a wobbly or backward start. Recognition again measures from the pointer-down point until the pointer lifts, as on main. Scrolling beside gestures, natively: - .from.scrollEdge() uses direction-specific touch-action while its scroller is at the edge (pan-x pan-down for drag-down at the top), the pull-to-refresh pattern from the Pointer Events spec. A passive scroll listener keeps it current; no touch handling blocks the browser. - Where directional values are unsupported (Safari), scroll-edge gestures alone fall back to claiming touches at the browser's first cancelable touchmove. - Hosts publish touch-action as --nvo-touch-action and the stylesheet applies it to descendants, so nested scroll containers keep it and horizontal swipes over lists are not cancelled. Ignored subtrees get touch-action: auto back. - Bindings gain listeners and ref (spread on the host); the components use them. Feel tests (npm run test:feel): Chromium with emulated touch checks recognition on ten human-like traces, frame pacing budgets while dragging (p95 <= 20ms, <= 5% over 20ms) for a swipe and a scroll-edge dismiss, and the behaviour checks. Against the previous code they fail on exactly the reported regressions; now 23/23 pass. On the demo, all traces match main and drag frame pacing matches or beats it. Commit thresholds are unchanged (0.25 distance, 0.2 flick). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { createApp, defineComponent, h, ref } from "vue";
|
||||
import "../../../src/style.css";
|
||||
import {
|
||||
OriginGesture,
|
||||
OriginGestureSurface,
|
||||
|
||||
@@ -165,6 +165,73 @@ const log = (page) => page.evaluate(() => window.log.slice());
|
||||
);
|
||||
await t.page.close();
|
||||
}
|
||||
// 5. Recognition feel: human-like traces on the Home swipe (over a nested
|
||||
// scroller). Real fingers rarely start cleanly; every trace that should open
|
||||
// must open, and the rest must not.
|
||||
{
|
||||
const steady = (total, steps, ms = 16) => Array.from({ length: steps }, () => [total / steps, 0, ms]);
|
||||
const traces = [
|
||||
["clean swipe", steady(-200, 12), true],
|
||||
["short steady drag (31%)", steady(-120, 10), true],
|
||||
["wobbly start, then swipe", [[-1, 4, 16], [-1, 3, 16], [-1, 2, 16], ...steady(-200, 12)], true],
|
||||
["wind-up 6px the wrong way", [[3, 0, 16], [3, 0, 16], ...steady(-206, 12)], true],
|
||||
["diagonal first 10px", [[-5, -5, 16], [-5, -4, 16], ...steady(-190, 11)], true],
|
||||
["backward twitch at lift-off", [...steady(-200, 12), [6, 0, 12]], true],
|
||||
["slow drag to 50%, hold, release", [...steady(-195, 40), [0, 0, 120]], true],
|
||||
["slow drag to 20%, hold, release", [...steady(-80, 20), [0, 0, 120]], false],
|
||||
["quick flick (15%)", steady(-60, 4, 10), true],
|
||||
["vertical scroll", Array.from({ length: 12 }, () => [1, -25, 16]), false],
|
||||
];
|
||||
for (const [name, steps, shouldOpen] of traces) {
|
||||
const t = await open();
|
||||
let [x, y] = [330, 400];
|
||||
await t.cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
|
||||
for (const [dx, dy, ms] of steps) {
|
||||
x += dx; y += dy;
|
||||
if (dx || dy) await t.cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x, y }] });
|
||||
await t.page.waitForTimeout(ms);
|
||||
}
|
||||
await t.cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
|
||||
await t.page.waitForTimeout(800);
|
||||
const opened = (await states(t.page)) === "root:parked detail:active";
|
||||
check(`recognition: ${name} ${shouldOpen ? "opens" : "stays"}`, opened === shouldOpen, opened ? "opened" : "stayed");
|
||||
await t.page.close();
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Frame pacing while a finger drags: no dropped frames from blocking
|
||||
// touch handling. Regressions measured p95 ≈ 28ms and ~30% of frames > 20ms.
|
||||
async function pacing(query, setup, from, to) {
|
||||
const gaps = [];
|
||||
for (let run = 0; run < 6; run++) {
|
||||
const t = await open(query);
|
||||
await setup(t);
|
||||
await t.page.evaluate(() => { window.f = []; const loop = () => { window.f.push(performance.now()); window.r = requestAnimationFrame(loop); }; window.r = requestAnimationFrame(loop); });
|
||||
await t.cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x: from[0], y: from[1] }] });
|
||||
for (let i = 1; i <= 40; i++) {
|
||||
await t.cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x: from[0] + ((to[0] - from[0]) * i) / 40, y: from[1] + ((to[1] - from[1]) * i) / 40 }] });
|
||||
await t.page.waitForTimeout(16);
|
||||
}
|
||||
const f = await t.page.evaluate(() => { cancelAnimationFrame(window.r); return window.f; });
|
||||
await t.cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
|
||||
gaps.push(...f.slice(1).map((v, i) => v - f[i]));
|
||||
await t.page.close();
|
||||
}
|
||||
gaps.sort((a, b) => a - b);
|
||||
return { p95: gaps[Math.floor(0.95 * (gaps.length - 1))], over20: gaps.filter((g) => g > 20).length / gaps.length };
|
||||
}
|
||||
const openDetail = async (t) => { await drag(t, [340, 400], [60, 405]); };
|
||||
for (const [name, result] of [
|
||||
["horizontal swipe drag", await pacing("", async () => {}, [340, 300], [40, 300])],
|
||||
["scroll-edge dismiss drag", await pacing("", openDetail, [200, 200], [200, 600])],
|
||||
]) {
|
||||
check(
|
||||
`pacing: ${name} p95 ≤ 20ms and ≤ 5% of frames over 20ms`,
|
||||
result.p95 <= 20 && result.over20 <= 0.05,
|
||||
`p95=${result.p95.toFixed(1)}ms over20=${(result.over20 * 100).toFixed(1)}%`,
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Continuity warning.
|
||||
for (const [name, expectWarning] of [
|
||||
["matched", false],
|
||||
|
||||
Reference in New Issue
Block a user