fix(spec): carry the container on an authored scroll

serializeAction sent the container's own point as both endpoints, so an
authored Scroll({in, direction}) reached the driver as a drag from a point to
itself and did nothing, on the seeded arm. The wire now carries the selector
and leaves the drag to the runner, which sizes it from the container's bounds
and has always had tested support for it that nothing could produce.

No rng runs in the serializer, which lowers an already-drawn action, so the
draw stream does not move. Builtin scrolls compute both endpoints and their
bytes are unchanged.

Claude-Session: https://claude.ai/code/session_01A5KmftdEJ49A9z5mF5ESrX
This commit is contained in:
pj committed 2026-08-12 23:51:01 +05:30
1 parent 70573a319e
commit 5ad2b39e6f
2 files changed
+69 -15

No files matched your search

+18 -10
View File
@@ -29,6 +29,7 @@ export type SerializedAction =
toX: number;
toY: number;
durationMillis: number;
selector?: string;
}
| { kind: "PressKey"; key: string }
| { kind: "Wait"; durationMillis: number };
@@ -90,20 +91,27 @@ export function serializeAction(action: ActionDescriptor | null): SerializedActi
};
}
case "Scroll": {
const from = pointOf(action.from) ?? pointOf(action.in);
if (!from) return null;
// The builtin generator pre-computes `to`; an author Scroll without it
// collapses to a zero-length gesture the runner re-derives from bounds.
const to = pointOf(action.to) ?? from;
return {
// The builtin generator pre-computes the whole gesture. An author names
// the container instead and leaves the drag to the runner, which sizes it
// from that container's bounds: sending the container's own point as both
// endpoints would be a drag from a point to itself, which the runner
// executes as written, and sending no container at all would scroll
// whatever else is on screen.
const from = pointOf(action.from);
const to = pointOf(action.to);
const gesture =
from && to
? { fromX: from.x, fromY: from.y, toX: to.x, toY: to.y }
: { fromX: 0, fromY: 0, toX: 0, toY: 0 };
const out: SerializedAction = {
kind: "Scroll",
direction: action.direction,
fromX: from.x,
fromY: from.y,
toX: to.x,
toY: to.y,
...gesture,
durationMillis: DEFAULT_SWIPE_DURATION,
};
const container = pointOf(action.in);
if (container?.selector) out.selector = container.selector;
return out;
}
case "PressKey":
return { kind: "PressKey", key: action.key };
+51 -5
View File
@@ -75,14 +75,60 @@ test("Swipe defaults durationMillis to 250", () => {
assert.equal(action.durationMillis, 250);
});
test("Scroll emits direction plus from/to point and duration", () => {
// An author Scroll names a container and leaves the drag to the runner, which
// sizes it from that container's bounds. Sending the container's own point as
// both endpoints would be a drag from a point to itself, which the runner takes
// at face value and dispatches as a zero-length gesture.
test("an author Scroll carries no endpoints", () => {
assert.deepEqual(serializeAction({ kind: "Scroll", direction: "down", in: POINT }), {
kind: "Scroll",
direction: "down",
fromX: 12,
fromY: 34,
toX: 12,
toY: 34,
fromX: 0,
fromY: 0,
toX: 0,
toY: 0,
durationMillis: 250,
});
});
test("an author Scroll names the container it scrolls", () => {
assert.deepEqual(serializeAction({ kind: "Scroll", direction: "down", in: "id:list" }), {
kind: "Scroll",
direction: "down",
fromX: 0,
fromY: 0,
toX: 0,
toY: 0,
durationMillis: 250,
selector: "id:list",
});
assert.deepEqual(
serializeAction({
kind: "Scroll",
direction: "down",
in: { x: 1, y: 2, selector: "id:list" },
}),
{
kind: "Scroll",
direction: "down",
fromX: 0,
fromY: 0,
toX: 0,
toY: 0,
durationMillis: 250,
selector: "id:list",
},
);
});
test("a Scroll with no container scrolls the screen rather than dropping", () => {
assert.deepEqual(serializeAction({ kind: "Scroll", direction: "up" }), {
kind: "Scroll",
direction: "up",
fromX: 0,
fromY: 0,
toX: 0,
toY: 0,
durationMillis: 250,
});
});