A kiosk pan stays a pan all the way to the release

The gesture is classified once, on the first movement past 8 px
(`_panLock`), but `_stagePointerUp` ignored that decision and asked
`swipeTarget()` again from the raw start→end vector — audit DEV-1DA1-02.

So a CURVED gesture could be both: a small vertical lead-in locked
`pan`, the plan started following the finger, the trajectory then swept
far sideways, and lifting the finger landed the user on another storey.
On a wall tablet that is the worst kind of surprise — you watch the plan
drag along and end up on a different floor.

The lock is now final: with `_panLock === 'pan'` the floor never
changes, whatever the overall vector looks like, and only a gesture
locked as `swipe` may reach `swipeTarget()`. A motionless tap locks
nothing, so the double-tap zoom reset is untouched.

Regression: demo/smoke_kiosk_pan_lock.mjs — the auditor's curved pan
(both directions and a long diagonal), the mirror case of a swipe that
bends vertically (it never pans, and if it stops qualifying it simply
does nothing), plus the straight swipe / straight pan / double tap /
zoomed-in cases. docs/CANVAS.md §5 and docs/TESTING.md updated.
This commit is contained in:
Sergey
2026-08-04 10:11:57 +03:00
parent 232c4807fd
commit 0af50a74ae
7 changed files with 175 additions and 4 deletions
File diff suppressed because one or more lines are too long
+138
View File
@@ -0,0 +1,138 @@
// A kiosk gesture is classified ONCE, and the release obeys that decision —
// audit DEV-1DA1-02 (P2).
//
// `_stagePointerMove` locks the gesture on the first movement past 8 px
// (`_panLock`: 'swipe' if it is horizontal enough inside the swipe zone, 'pan'
// otherwise) and the plan then follows the finger. But `_stagePointerUp` used
// to ignore that lock and ask `swipeTarget()` again, from the raw start→end
// vector alone. A CURVED gesture — a small vertical lead-in that locks 'pan',
// then a long horizontal sweep — therefore panned under the finger and still
// switched the floor on release. On a wall tablet that is the worst kind of
// surprise: you watch the plan drag along and land on another storey.
//
// The lock is now final: with `_panLock === 'pan'` the floor never changes, no
// matter what the overall vector looks like. The two straight gestures keep
// their old behaviour, and so does the motionless double tap (no movement,
// no lock — the swipe path is never even reached by it).
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
const o = {};
const c0 = window.__card;
const k = document.createElement('houseplan-card');
k.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
k.hass = c0.hass;
document.body.appendChild(k);
k.style.cssText = 'position:fixed;left:0;top:0;width:900px;height:700px;z-index:99';
await new Promise((r) => setTimeout(r, 500));
k.hass = { ...c0.hass };
await k.updateComplete;
const sr = k.shadowRoot || k.renderRoot;
const stage = sr.querySelector('.stage');
const fire = (type, id, x, y) => stage.dispatchEvent(new PointerEvent(type, {
bubbles: true, composed: true, cancelable: true, pointerId: id, clientX: x, clientY: y,
}));
o.kioskHasSeveralSpaces = k._model.length > 1;
const home = async () => {
const vb = k._baseVb();
k._applyView(1, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2);
await k.updateComplete;
};
/**
* Play one trajectory and report what the gesture decided and what it did.
* `pts` are the moves; the finger lifts at the last one.
*/
const play = async (id, from, pts) => {
await home();
const s0 = k._space;
const v0 = { ...k._viewOr(k._baseVb()) };
fire('pointerdown', id, from[0], from[1]);
let lockAfterLeadIn = null;
let moved = false;
pts.forEach((p, i) => {
fire('pointermove', id, p[0], p[1]);
if (i === 0) lockAfterLeadIn = k._panLock;
if (Math.abs(k._viewOr(k._baseVb()).x - v0.x) > 1
|| Math.abs(k._viewOr(k._baseVb()).y - v0.y) > 1) moved = true;
});
const lockBeforeRelease = k._panLock;
const last = pts[pts.length - 1];
fire('pointerup', id, last[0], last[1]);
await k.updateComplete;
return { s0, s1: k._space, lockAfterLeadIn, lockBeforeRelease, moved };
};
// ---- 1. the auditor's curved pan: vertical lead-in, horizontal ending --
// (500,300) → (502,312) locks 'pan' → curve left to (350,304) → release.
// dx = -150, dy = +4: swipeTarget() would happily call that a swipe.
const curvedPan = await play(51, [500, 300], [[502, 312], [460, 310], [400, 306], [350, 304]]);
o.curvedPanLocksPan = curvedPan.lockAfterLeadIn === 'pan';
o.curvedPanKeepsTheLock = curvedPan.lockBeforeRelease === 'pan';
o.curvedPanActuallyPans = curvedPan.moved === true;
o.curvedPanKeepsTheFloor = curvedPan.s1 === curvedPan.s0;
// the same trajectory the other way round — a swipe to the right would have
// been the previous floor, so the bug is symmetric and so is the fix
const curvedPanRight = await play(52, [400, 300], [[402, 312], [460, 308], [520, 305], [560, 304]]);
o.curvedPanRightLocksPan = curvedPanRight.lockAfterLeadIn === 'pan';
o.curvedPanRightKeepsTheFloor = curvedPanRight.s1 === curvedPanRight.s0;
// a long diagonal that ends up dominated by x, but started as a pan
const diagonalPan = await play(53, [500, 300], [[496, 316], [420, 340], [330, 350], [260, 352]]);
o.diagonalPanLocksPan = diagonalPan.lockAfterLeadIn === 'pan';
o.diagonalPanKeepsTheFloor = diagonalPan.s1 === diagonalPan.s0;
// ---- 2. a gesture locked as a SWIPE keeps its own semantics ------------
// horizontal lead-in locks 'swipe'; the plan must not slide under it, even
// when the trajectory then bends vertically and the final vector no longer
// qualifies — the floor simply stays, and nothing pans
const curvedSwipe = await play(54, [600, 300], [[540, 302], [536, 380], [545, 500]]);
o.curvedSwipeLocksSwipe = curvedSwipe.lockAfterLeadIn === 'swipe';
o.curvedSwipeNeverPans = curvedSwipe.moved === false;
o.curvedSwipeThatDiesChangesNothing = curvedSwipe.s1 === curvedSwipe.s0;
// a swipe that bends but still ends as a swipe does switch the floor
const bentSwipe = await play(55, [600, 300], [[540, 302], [470, 330], [420, 340]]);
o.bentSwipeLocksSwipe = bentSwipe.lockAfterLeadIn === 'swipe';
o.bentSwipeNeverPans = bentSwipe.moved === false;
o.bentSwipeStillSwitches = bentSwipe.s1 !== bentSwipe.s0;
// ---- 3. the straight gestures are exactly as they were -----------------
const straightSwipe = await play(56, [600, 300], [[540, 302], [480, 305], [450, 305]]);
o.straightSwipeSwitches = straightSwipe.s1 !== straightSwipe.s0;
o.straightSwipeDoesNotPan = straightSwipe.moved === false;
const straightPan = await play(57, [450, 200], [[452, 260], [454, 330]]);
o.straightPanPans = straightPan.moved === true;
o.straightPanKeepsTheFloor = straightPan.s1 === straightPan.s0;
// ---- 4. a motionless double tap still resets the zoom ------------------
// no movement means no lock at all, so nothing above can reach this path
await home();
k._applyView(2);
await k.updateComplete;
k._lastTap = Date.now() - 100;
fire('pointerdown', 58, 500, 300);
fire('pointerup', 58, 501, 300);
await k.updateComplete;
o.doubleTapStillResetsZoom = k._zoom === 1;
o.doubleTapLeavesNoLock = k._panLock === null;
// ---- 5. zoomed in there is no swipe zone, and the lock says so ---------
k._applyView(2);
await k.updateComplete;
const zoomed = await (async () => {
const s0 = k._space;
fire('pointerdown', 59, 600, 300);
fire('pointermove', 59, 540, 302);
const lock = k._panLock;
fire('pointermove', 59, 480, 305);
fire('pointerup', 59, 480, 305);
await k.updateComplete;
return { lock, same: k._space === s0 };
})();
o.zoomedHorizontalLocksPan = zoomed.lock === 'pan';
o.zoomedHorizontalKeepsTheFloor = zoomed.same;
k.remove();
return o;
});
checkAll(out);
await finish(browser, out);
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+10
View File
@@ -197,6 +197,16 @@ was silently excluded from the frame).
the gesture is classified once, on the first movement past 8 px, and
keeps that role until the finger lifts (`_panLock`), so the plan
never slides under a swipe and a vertical drag still pans.
* **The lock is final, at the release too** (audit DEV-1DA1-02). The
release used to ask `swipeTarget()` again from the raw start→end
vector, ignoring the lock — so a *curved* gesture (a short vertical
lead-in that locks `pan`, then a long horizontal sweep) dragged the
plan under the finger and still landed on another storey when it
lifted: the worst kind of surprise on a wall tablet. `_panLock ===
'pan'` now means no floor change, whatever the overall vector ends up
looking like; only a gesture locked as `swipe` may reach
`swipeTarget()`, and it never pans on the way. A motionless tap locks
nothing, so the double-tap zoom reset is untouched.
* **"Home is that way" arrow** — when the content frame is entirely
outside the current view, a small pointer appears at the view edge
in the frame's direction. Clicking it fits the content. Cheap
+11
View File
@@ -1065,6 +1065,17 @@ require hands on real hardware — they remain for the human pass.
editor moves the device, an opening slides along its wall — none of them
pan), two fingers still pinch, and on a kiosk screen a horizontal drag
is still the floor swipe (a vertical one pans) [auto: smoke_pan_any_zoom]
- [ ] **Kiosk: a pan stays a pan to the very end** (dev, audit DEV-1DA1-02):
on a wall tablet at 100 % start a drag with a small VERTICAL lead-in
(the plan starts following the finger — the gesture is locked as `pan`),
then curve it far to the left or right and lift. The floor must NOT
change: the decision taken on the first movement is final, and only a
gesture locked as a swipe may switch storeys. Mirror check: a horizontal
lead-in locks the swipe — the plan never slides under it, and if the
trajectory then bends vertically and no longer qualifies as a swipe, the
gesture simply does nothing (it does not turn into a pan). Straight
swipes still switch, straight vertical drags still pan, a motionless
double tap still resets the zoom [auto: smoke_kiosk_pan_lock]
- [ ] **Adaptive grid** (§7): in the Plan editor zoomed far out the grid does
not merge into a grey wash — fine dots thin out, every 5th/10th node
stays bigger; zoomed in the grid is the usual one and snapping still
+13 -1
View File
@@ -2369,7 +2369,19 @@ class HouseplanCard extends LitElement {
if (now - this._lastTap < 350) this._resetZoom();
this._lastTap = now;
}
const target = swipeTarget(dx, dy, this._zoom, this._model.map((m) => m.id), this._space);
// The lock is FINAL (audit DEV-1DA1-02). `_stagePointerMove` decided
// once, on the first movement worth the name, whether this gesture is
// a swipe or a pan — and the release may not overturn it. Until this
// the release asked `swipeTarget()` again from the raw start→end
// vector, so a CURVED gesture (a small vertical lead-in that locks
// 'pan', then a long horizontal sweep) dragged the plan under the
// finger and still landed on another storey when it lifted. A pan is
// a pan to the end: no floor change, whatever the overall vector
// happens to look like. A motionless tap never locks anything, so the
// double-tap zoom reset above is untouched.
const target = this._panLock === 'pan'
? null
: swipeTarget(dx, dy, this._zoom, this._model.map((m) => m.id), this._space);
if (target) {
// the plan follows the finger: swiping left brings the next one in
// from the right, so the current one leaves to the left