@swmansion/argent 0.16.2-next.3 → 0.16.2-next.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -126717,6 +126717,12 @@ var zodSchema21 = external_exports.object({
126717
126717
  endDistance: external_exports.number().describe(
126718
126718
  "Final distance between the two fingers: normalized 0.0\u20131.0 (fraction of screen, not pixels). E.g. 0.6 = fingers 60% of screen apart. Use a larger endDistance than startDistance to pinch out (zoom in)."
126719
126719
  ),
126720
+ endCenterX: external_exports.number().optional().describe(
126721
+ "Final horizontal center of the pinch: normalized 0.0\u20131.0. When set, the centroid drifts linearly from centerX to endCenterX over the gesture (e.g. to keep expanding fingers on-screen near an edge). Omit for a fixed center."
126722
+ ),
126723
+ endCenterY: external_exports.number().optional().describe(
126724
+ "Final vertical center of the pinch: normalized 0.0\u20131.0. When set, the centroid drifts linearly from centerY to endCenterY over the gesture. Omit for a fixed center."
126725
+ ),
126720
126726
  angle: external_exports.number().optional().describe("Axis angle in degrees along which the fingers are placed (default 0 = horizontal)."),
126721
126727
  durationMs: external_exports.number().optional().describe("Total gesture duration in milliseconds (default 300)")
126722
126728
  });
@@ -126730,7 +126736,7 @@ var gesturePinchTool = {
126730
126736
  description: `Execute a pinch-to-zoom gesture by moving two fingers toward or away from a center point to change the scale of on-screen content. All positions and distances are normalized 0.0\u20131.0 (fractions of screen width/height, not pixels)\u2014same coordinate space as gesture-tap and gesture-swipe.
126731
126737
  startDistance > endDistance = pinch in (zoom out). startDistance < endDistance = pinch out (zoom in).
126732
126738
  Typical values: startDistance 0.2, endDistance 0.6 for a zoom-in pinch at screen center.
126733
- Auto-generates interpolated frames at ~60fps. The angle parameter controls the axis (0 = horizontal, 90 = vertical).
126739
+ Auto-generates interpolated frames at ~60fps. The angle parameter controls the axis (0 = horizontal, 90 = vertical). Optional endCenterX/endCenterY drift the centroid linearly over the gesture (omitted = fixed center).
126734
126740
  Use when you need to zoom in or out on a map, image, or zoomable view. Returns { pinched: true, timestampMs }. Fails if the simulator-server / emulator backend is not reachable for the given device.`,
126735
126741
  zodSchema: zodSchema21,
126736
126742
  capability: capability13,
@@ -126745,15 +126751,19 @@ Use when you need to zoom in or out on a map, image, or zoomable view. Returns {
126745
126751
  const angleRad = angleDeg * Math.PI / 180;
126746
126752
  const cosA = Math.cos(angleRad);
126747
126753
  const sinA = Math.sin(angleRad);
126754
+ const endCenterX = params.endCenterX ?? params.centerX;
126755
+ const endCenterY = params.endCenterY ?? params.centerY;
126748
126756
  let timestampMs = 0;
126749
126757
  for (let i = 0; i <= steps; i++) {
126750
126758
  const t = i / steps;
126751
126759
  const dist = params.startDistance + (params.endDistance - params.startDistance) * t;
126752
126760
  const halfDist = dist / 2;
126753
- const x1 = params.centerX - halfDist * cosA;
126754
- const y1 = params.centerY - halfDist * sinA;
126755
- const x2 = params.centerX + halfDist * cosA;
126756
- const y2 = params.centerY + halfDist * sinA;
126761
+ const cx = params.centerX + (endCenterX - params.centerX) * t;
126762
+ const cy = params.centerY + (endCenterY - params.centerY) * t;
126763
+ const x1 = cx - halfDist * cosA;
126764
+ const y1 = cy - halfDist * sinA;
126765
+ const x2 = cx + halfDist * cosA;
126766
+ const y2 = cy + halfDist * sinA;
126757
126767
  const type = i === 0 ? "Down" : i === steps ? "Up" : "Move";
126758
126768
  if (i === 0) timestampMs = Date.now();
126759
126769
  sendTouchEvent(api, type, x1, y1, x2, y2);
@@ -133889,8 +133899,8 @@ Allowed tools and their args (udid is auto-injected, do NOT include it in args):
133889
133899
  gesture-scroll: { x: number, y: number, deltaX?: number, deltaY?: number, durationMs?: number } [chromium only]
133890
133900
  gesture-drag: { fromX: number, fromY: number, toX: number, toY: number, durationMs?: number } [chromium only]
133891
133901
  gesture-custom: { events: [{ type: "Down"|"Move"|"Up", x: number, y: number, x2?: number, y2?: number, delayMs?: number }], interpolate?: number } [ios/android]
133892
- gesture-pinch: { centerX: number, centerY: number, startDistance: number, endDistance: number, angle?: number, durationMs?: number } [ios only]
133893
- gesture-rotate: { centerX: number, centerY: number, radius: number, startAngle: number, endAngle: number, durationMs?: number } [ios only]
133902
+ gesture-pinch: { centerX: number, centerY: number, startDistance: number, endDistance: number, endCenterX?: number, endCenterY?: number, angle?: number, durationMs?: number } [ios/android]
133903
+ gesture-rotate: { centerX: number, centerY: number, radius: number, startAngle: number, endAngle: number, durationMs?: number } [ios/android]
133894
133904
  button: { button: "home"|"back"|"power"|"volumeUp"|"volumeDown"|"appSwitch"|"actionButton" } [ios/android]
133895
133905
  keyboard: { text?: string, key?: string, delayMs?: number } (key pressed after text; TV: text only) [ios/android/chromium/vega/tv]
133896
133906
  text supports {{secret:<NAME>}} placeholders, resolved server-side from ARGENT_SECRET_<NAME> env vars (prefix mandatory) \u2014 credentials never enter agent context
@@ -145031,6 +145041,10 @@ function toYamlStep(step) {
145031
145041
  }
145032
145042
  };
145033
145043
  }
145044
+ case "pinch":
145045
+ return {
145046
+ pinch: step.selector ? { on: selectorToYaml(step.selector), scale: step.scale } : { scale: step.scale }
145047
+ };
145034
145048
  case "snapshot":
145035
145049
  return step.maxMismatch === void 0 ? { snapshot: step.name } : { snapshot: { name: step.name, maxMismatch: step.maxMismatch } };
145036
145050
  case "tool":
@@ -145303,6 +145317,7 @@ var STEP_DIRECTIVE_KEYS = [
145303
145317
  "assert",
145304
145318
  "wait",
145305
145319
  "scroll-to",
145320
+ "pinch",
145306
145321
  "snapshot"
145307
145322
  ];
145308
145323
  function parseTapTimes(raw, entry) {
@@ -145399,6 +145414,28 @@ function parseLongPress(body, entry) {
145399
145414
  }
145400
145415
  return { kind: "long-press", ...parseTarget(body, "long-press") };
145401
145416
  }
145417
+ function parsePinch(body, entry) {
145418
+ if (body === null || typeof body !== "object") {
145419
+ badEntry(
145420
+ entry,
145421
+ 'pinch takes an options map \u2014 e.g. pinch: { on: "Map", scale: 3 } (a bare "pinch: Map" is ambiguous: in or out?)'
145422
+ );
145423
+ }
145424
+ const obj = body;
145425
+ if (obj.text !== void 0 || obj.id !== void 0 || obj.identifier !== void 0 || obj.role !== void 0) {
145426
+ badEntry(entry, 'pinch takes a nested selector \u2014 e.g. pinch: { on: "Map", scale: 3 }');
145427
+ }
145428
+ rejectUnknownKeys(entry, obj, ["on", "scale"], "pinch");
145429
+ if (typeof obj.scale !== "number" || !Number.isFinite(obj.scale) || obj.scale <= 0 || obj.scale === 1) {
145430
+ badEntry(
145431
+ entry,
145432
+ "pinch.scale must be a finite number > 0 and \u2260 1 (2 = zoom in 2\xD7, 0.5 = zoom out to half)"
145433
+ );
145434
+ }
145435
+ const step = { kind: "pinch", scale: obj.scale };
145436
+ if (obj.on !== void 0) step.selector = parseSelector(obj.on, "pinch.on");
145437
+ return step;
145438
+ }
145402
145439
  function parseWhenCondition(raw) {
145403
145440
  const conditionKeys = `${WAIT_CONDITIONS.join(", ")}, platform`;
145404
145441
  if (raw === null || typeof raw !== "object") {
@@ -145566,6 +145603,7 @@ function fromYamlStep(raw, whenDepth = 0) {
145566
145603
  if (b.within !== void 0) step.within = parseSelector(b.within, "scroll-to.within");
145567
145604
  return step;
145568
145605
  }
145606
+ if ("pinch" in raw) return parsePinch(raw.pinch, raw);
145569
145607
  if ("snapshot" in raw) {
145570
145608
  const body = raw.snapshot;
145571
145609
  if (body !== null && typeof body === "object" && !Array.isArray(body)) {
@@ -146462,6 +146500,8 @@ You can still edit the .yaml file directly afterwards to remove or reorder steps
146462
146500
  }
146463
146501
  case "scroll-to":
146464
146502
  return `${n}. scroll-to: ${selectorLabel(step.target)} (${step.direction})`;
146503
+ case "pinch":
146504
+ return `${n}. pinch: scale ${step.scale}${step.selector ? ` on ${selectorLabel(step.selector)}` : ""}`;
146465
146505
  case "snapshot":
146466
146506
  return `${n}. snapshot: ${step.name}`;
146467
146507
  case "tool":
@@ -146488,6 +146528,73 @@ var fs41 = __toESM(require("node:fs/promises"));
146488
146528
  var path31 = __toESM(require("node:path"));
146489
146529
  init_src();
146490
146530
 
146531
+ // ../tool-server/src/tools/flows/flow-pinch-geometry.ts
146532
+ var PINCH_RATIO_PER_GESTURE = 4;
146533
+ var PINCH_SETTLE_MS = 250;
146534
+ var TARGET_START_FRACTION = 0.82;
146535
+ var SCREEN_EDGE_INSET = 0.02;
146536
+ var MIN_VIABLE_TRAVEL = 0.03;
146537
+ function systemEdgeGuards(device) {
146538
+ return device.platform === "android" ? { left: 0.13, right: 0.13, top: 0.08, bottom: 0.08 } : { left: 0.08, right: 0.08, top: 0.08, bottom: 0.08 };
146539
+ }
146540
+ function decomposePinch(scale) {
146541
+ const n = Math.max(
146542
+ 1,
146543
+ Math.ceil(Math.abs(Math.log(scale)) / Math.log(PINCH_RATIO_PER_GESTURE) - 1e-9)
146544
+ );
146545
+ return { n, per: scale ** (1 / n) };
146546
+ }
146547
+ function clamp2(v, lo, hi) {
146548
+ return v < lo ? lo : v > hi ? hi : v;
146549
+ }
146550
+ function buildAxisCandidate(input) {
146551
+ const { angle, center, targetSpan, per, guards } = input;
146552
+ const c = angle === 0 ? center.x : center.y;
146553
+ const gLow = angle === 0 ? guards.left : guards.top;
146554
+ const gHigh = angle === 0 ? guards.right : guards.bottom;
146555
+ const p = angle === 0 ? center.y : center.x;
146556
+ const pLow = angle === 0 ? guards.top : guards.left;
146557
+ const pHigh = angle === 0 ? guards.bottom : guards.right;
146558
+ const screenEndSpan = 1 - 2 * SCREEN_EDGE_INSET;
146559
+ const edgeSafeStartSpan = 2 * Math.max(0, Math.min(c - gLow, 1 - gHigh - c));
146560
+ const insetStartSpan = 2 * Math.max(0, Math.min(c - SCREEN_EDGE_INSET, 1 - SCREEN_EDGE_INSET - c));
146561
+ const screenStartLimit = edgeSafeStartSpan > 0 ? Math.min(screenEndSpan, edgeSafeStartSpan) : Math.min(screenEndSpan, insetStartSpan);
146562
+ const startLimit = targetSpan !== void 0 ? Math.min(screenStartLimit, targetSpan * TARGET_START_FRACTION) : screenStartLimit;
146563
+ const start2 = per > 1 ? Math.min(startLimit, screenEndSpan / per) : startLimit;
146564
+ const end = Math.min(screenEndSpan, start2 * per);
146565
+ const travel = Math.abs(end - start2);
146566
+ if (!(travel > 0)) return void 0;
146567
+ const endCenter = clamp2(c, SCREEN_EDGE_INSET + end / 2, 1 - SCREEN_EDGE_INSET - end / 2);
146568
+ const downLow = c - start2 / 2;
146569
+ const downHigh = c + start2 / 2;
146570
+ const axisSafe = downLow >= gLow && downHigh <= 1 - gHigh;
146571
+ const perpSafe = p >= pLow && p <= 1 - pHigh;
146572
+ const clearance = Math.min(downLow - gLow, 1 - gHigh - downHigh, p - pLow, 1 - pHigh - p);
146573
+ return {
146574
+ angle,
146575
+ start: start2,
146576
+ end,
146577
+ endCenter,
146578
+ travel,
146579
+ viable: travel >= MIN_VIABLE_TRAVEL,
146580
+ axisSafe,
146581
+ fullyEdgeSafe: axisSafe && perpSafe,
146582
+ clearance
146583
+ };
146584
+ }
146585
+ function selectPinchCandidate(candidates) {
146586
+ if (candidates.length === 0) return void 0;
146587
+ const rank2 = (c) => c.fullyEdgeSafe ? 0 : c.axisSafe ? 1 : 2;
146588
+ return [...candidates].sort((a, b) => {
146589
+ const byViable = Number(b.viable) - Number(a.viable);
146590
+ if (byViable !== 0) return byViable;
146591
+ const byRank = rank2(a) - rank2(b);
146592
+ if (byRank !== 0) return byRank;
146593
+ if (a.fullyEdgeSafe && b.fullyEdgeSafe) return b.travel - a.travel;
146594
+ return b.clearance - a.clearance || b.travel - a.travel;
146595
+ })[0];
146596
+ }
146597
+
146491
146598
  // ../tool-server/src/tools/flows/flow-actions.ts
146492
146599
  var ABORTED_OUTCOME = {
146493
146600
  ok: false,
@@ -146701,12 +146808,18 @@ function offscreenHint(sel) {
146701
146808
  return `no visible element matched selector ${describeSelector(sel)} \u2014 if it is off-screen, add a scroll-to step before this one`;
146702
146809
  }
146703
146810
  async function runDirective(env, step) {
146704
- if (env.device.platform === "vega" && (step.kind === "tap" || step.kind === "long-press" || step.kind === "type" || step.kind === "scroll-to")) {
146811
+ if (env.device.platform === "vega" && (step.kind === "tap" || step.kind === "long-press" || step.kind === "type" || step.kind === "scroll-to" || step.kind === "pinch")) {
146705
146812
  return {
146706
146813
  ok: false,
146707
146814
  reason: `${step.kind} is a touch directive and Vega is remote-driven \u2014 move focus with \`tool: tv-remote\` steps (and type via \`tool: keyboard\`) instead`
146708
146815
  };
146709
146816
  }
146817
+ if (step.kind === "pinch" && env.device.platform === "chromium") {
146818
+ return {
146819
+ ok: false,
146820
+ reason: "pinch is unsupported on chromium \u2014 desktop apps have no uniform pinch-zoom mapping (they zoom via ctrl+wheel or their own controls); drive the app's zoom UI with tap/keyboard instead"
146821
+ };
146822
+ }
146710
146823
  switch (step.kind) {
146711
146824
  case "tap":
146712
146825
  return runTap(env, step);
@@ -146723,6 +146836,8 @@ async function runDirective(env, step) {
146723
146836
  if (r.aborted) return ABORTED_OUTCOME;
146724
146837
  return { ok: Boolean(r.frame), reason: r.reason };
146725
146838
  }
146839
+ case "pinch":
146840
+ return runPinch(env, step);
146726
146841
  }
146727
146842
  }
146728
146843
  async function resolveTargetPoint(env, target) {
@@ -146771,6 +146886,47 @@ async function runLongPress(env, step) {
146771
146886
  }
146772
146887
  return { ok: true };
146773
146888
  }
146889
+ async function runPinch(env, step) {
146890
+ let center = { x: 0.5, y: 0.5 };
146891
+ let frame;
146892
+ if (step.selector) {
146893
+ const resolved = await waitForFrame(env, step.selector);
146894
+ if (resolved === "aborted") return ABORTED_OUTCOME;
146895
+ if (!resolved) return { ok: false, reason: offscreenHint(step.selector) };
146896
+ frame = resolved;
146897
+ center = getDescribeTapPoint(resolved);
146898
+ }
146899
+ const { n, per } = decomposePinch(step.scale);
146900
+ const guards = systemEdgeGuards(env.device);
146901
+ const candidates = [
146902
+ buildAxisCandidate({ angle: 0, center, targetSpan: frame?.width, per, guards }),
146903
+ buildAxisCandidate({ angle: 90, center, targetSpan: frame?.height, per, guards })
146904
+ ].filter((c) => c !== void 0);
146905
+ const selected = selectPinchCandidate(candidates);
146906
+ if (!selected) {
146907
+ return {
146908
+ ok: false,
146909
+ reason: `pinch found no on-screen finger travel around (${center.x}, ${center.y})`
146910
+ };
146911
+ }
146912
+ const args = {
146913
+ centerX: center.x,
146914
+ centerY: center.y,
146915
+ startDistance: selected.start,
146916
+ endDistance: selected.end,
146917
+ angle: selected.angle
146918
+ };
146919
+ const startCenter = selected.angle === 0 ? center.x : center.y;
146920
+ if (selected.endCenter !== startCenter) {
146921
+ args[selected.angle === 0 ? "endCenterX" : "endCenterY"] = selected.endCenter;
146922
+ }
146923
+ for (let i = 0; i < n; i++) {
146924
+ if (env.signal?.aborted) return ABORTED_OUTCOME;
146925
+ await invokeOnDevice(env, "gesture-pinch", args);
146926
+ if (i < n - 1 && !await sleepOrAbort(PINCH_SETTLE_MS, env.signal)) return ABORTED_OUTCOME;
146927
+ }
146928
+ return { ok: true };
146929
+ }
146774
146930
  async function runType(env, step) {
146775
146931
  const frame = await waitForFrame(env, step.into);
146776
146932
  if (frame === "aborted") return ABORTED_OUTCOME;
@@ -148394,7 +148550,7 @@ function formatSignedNumber(value) {
148394
148550
  return rounded > 0 ? `+${rounded}` : `${rounded}`;
148395
148551
  }
148396
148552
  function formatNormalizedPosition(value, divisor) {
148397
- return formatNormalizedNumber(clamp2(value / divisor, 0, 1));
148553
+ return formatNormalizedNumber(clamp3(value / divisor, 0, 1));
148398
148554
  }
148399
148555
  function formatNormalizedNumber(value) {
148400
148556
  if (!Number.isFinite(value)) return "0";
@@ -148410,7 +148566,7 @@ function formatSignedNormalizedNumber(value) {
148410
148566
  const formatted = formatNormalizedNumber(value);
148411
148567
  return value > 0 && formatted !== "0" ? `+${formatted}` : formatted;
148412
148568
  }
148413
- function clamp2(value, min, max) {
148569
+ function clamp3(value, min, max) {
148414
148570
  return Math.min(max, Math.max(min, value));
148415
148571
  }
148416
148572
  function formatPercentage(value) {
@@ -149230,8 +149386,9 @@ Steps run in order: \`launch\` starts an app from scratch (terminate + relaunch)
149230
149386
  ready; \`tool\` calls dispatch through the registry; \`tap\`/\`long-press\`/\`type\` resolve a selector to an
149231
149387
  element and act on it (\`tap: { on, times: 2 }\` double-taps; \`long-press: { on, duration }\` presses and
149232
149388
  holds; \`tap\`/\`long-press\` alternatively take a raw normalized point \u2014 bare \`{ x, y }\` or \`on: { x, y }\`);
149233
- \`scroll-to\` scrolls (momentum-free) until a target is visible; \`await\` waits for a UI
149234
- condition; \`wait\` pauses for a fixed number of milliseconds; \`assert\` checks one now; \`snapshot\`
149389
+ \`scroll-to\` scrolls (momentum-free) until a target is visible; \`pinch\` zooms
149390
+ (\`pinch: { on?, scale }\` \u2014 scale > 1 in, < 1 out; screen center when \`on\` is omitted); \`await\` waits
149391
+ for a UI condition; \`wait\` pauses for a fixed number of milliseconds; \`assert\` checks one now; \`snapshot\`
149235
149392
  diffs a screenshot against a stored baseline (a missing baseline fails the step \u2014 set updateBaselines
149236
149393
  to adopt the current screen); \`echo\` annotates; \`run\` executes a referenced fragment inline.
149237
149394
  A \`when:\` block (condition + \`steps:\`, no else) runs its steps only if the condition holds \u2014
@@ -149415,6 +149572,10 @@ function stepTarget(step) {
149415
149572
  const dir = step.direction !== "down" ? ` (${step.direction})` : "";
149416
149573
  return `${selectorLabel2(step.target)}${dir}`;
149417
149574
  }
149575
+ case "pinch": {
149576
+ const scale = `scale ${step.scale}`;
149577
+ return step.selector ? `${selectorLabel2(step.selector)} (${scale})` : scale;
149578
+ }
149418
149579
  case "snapshot":
149419
149580
  return `"${step.name}"`;
149420
149581
  default:
@@ -149596,7 +149757,8 @@ async function execLeafStep(state3, step, index, scope) {
149596
149757
  case "type":
149597
149758
  case "await":
149598
149759
  case "assert":
149599
- case "scroll-to": {
149760
+ case "scroll-to":
149761
+ case "pinch": {
149600
149762
  try {
149601
149763
  const r = await runDirective(state3, step);
149602
149764
  if (r.aborted) return { ...base, status: "skip", reason: r.reason };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swmansion/argent",
3
- "version": "0.16.2-next.3",
3
+ "version": "0.16.2-next.5",
4
4
  "description": "MCP server for iOS Simulator and Android Emulator control",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -27,6 +27,7 @@ Beyond raw `tool:` steps and `echo:`, flows support declarative directives inter
27
27
  | `long-press` | `- long-press: Row 3`, `- long-press: { x: 0.5, y: 0.6 }`, `- long-press: { on: <sel>, duration: 1200 }`, `- long-press: { on: { x: 0.5, y: 0.6 }, duration: 1200 }` | press and hold an element or raw point (default 800ms; Chromium: mouse press-hold); `duration` needs the target nested under `on:` — a selector or a point |
28
28
  | `type` | `- type: { into: email, text: "a@b.com" }` | focus a field, type, then press Enter to submit + dismiss the keyboard |
29
29
  | `scroll-to` | `- scroll-to: "Order #1234"` (scrolls down) or `- scroll-to: { target: …, direction: right, within: … }` | momentum-free scroll until the target is visible |
30
+ | `pinch` | `- pinch: { on: "Map", scale: 3 }` or `- pinch: { scale: 0.5 }` | two-finger zoom in (`scale` > 1) or out (`< 1`); big scales chain gestures; `on` optional — defaults to screen center; open-loop — assert the visible result |
30
31
  | `await` | `- await: { visible: Home }` | wait for a UI condition |
31
32
  | `wait` | `- wait: 500` | pause for a fixed number of milliseconds (last resort — prefer `await`) |
32
33
  | `assert` | `- assert: { visible: Welcome }` | check a condition, hard-fail if it never holds |
@@ -67,7 +68,7 @@ Never record a real credential into a flow — the YAML is committed to the repo
67
68
 
68
69
  ### TV targets (Vega)
69
70
 
70
- A Vega (Fire TV) device is remote-driven — there is no touch input, so the touch directives (`tap`, `long-press`, `type`, `scroll-to`) fail on it with guidance. Drive focus with `tool: tv-remote` steps and type with `tool: keyboard` instead; everything else (`launch`, `await`, `assert`, `wait`, `snapshot`, `echo`, `run`, selectors) works unchanged — the tree comes from the on-device automation toolkit, which attaches at app launch (the `launch` step waits for it, so a leading `launch` also guarantees selectors resolve).
71
+ A Vega (Fire TV) device is remote-driven — there is no touch input, so the touch directives (`tap`, `long-press`, `type`, `scroll-to`, `pinch`) fail on it with guidance. Drive focus with `tool: tv-remote` steps and type with `tool: keyboard` instead; everything else (`launch`, `await`, `assert`, `wait`, `snapshot`, `echo`, `run`, selectors) works unchanged — the tree comes from the on-device automation toolkit, which attaches at app launch (the `launch` step waits for it, so a leading `launch` also guarantees selectors resolve).
71
72
 
72
73
  ```yaml
73
74
  steps:
@@ -127,9 +128,10 @@ Record an `await-ui-element` step to **gate** the next step on a screen transiti
127
128
  5. **Polish**: **read the saved `.yaml` file** and convert the raw `tool:` steps that have a cleaner directive form (the recorder leaves these as tools):
128
129
  - `tool: keyboard` typing into a field → `type: { into: "<field>", text: "…" }`, folding in the `tap` that focused the field.
129
130
  - `tool: await-ui-element` gating a transition → `await: { visible: "…" }` / `{ hidden: … }` / `{ text: { in: …, equals: … } }`, carrying a custom `timeoutMs` over as a `timeout` sibling key. Converting also upgrades the wait from the trimmed `describe` tree to the flow's full-hierarchy tree (see Selectors). Keep the raw `tool: await-ui-element` step only when it sets a custom `pollIntervalMs`/`bundleId` the directive can't express.
130
- - A scroll-to-reach-an-element — a `tool: gesture-swipe` used to bring a specific element on screen before interacting with it (a `tap`, `type`, `assert`, …) → `scroll-to: { target: "<that element>", direction: … }`, dropping the swipe. This is far more robust than a fixed-distance swipe: it scrolls momentum-free and stops exactly when the target appears, so it survives layout and content changes. (`tap`/`type` do not scroll, so a raw swipe whose fling lands differently on another device leaves the following tap unresolved — always prefer the `scroll-to` rewrite.) Keep a `gesture-swipe` as a raw `tool:` step when it isn't scrolling toward a specific element — especially a velocity-dependent gesture like swipe-to-dismiss, edge-swipe-back, or swipe-to-reveal a row action, which a momentum-free `scroll-to` would not reproduce.
131
+ - A scroll-to-reach-an-element — a `tool: gesture-swipe` (or its chromium analog, `gesture-scroll`) used to bring a specific element on screen before interacting with it (a `tap`, `type`, `assert`, …) → `scroll-to: { target: "<that element>", direction: … }`, dropping the swipe. This is far more robust than a fixed-distance swipe: it scrolls momentum-free and stops exactly when the target appears, so it survives layout and content changes. (`tap`/`type` do not scroll, so a raw swipe whose fling lands differently on another device leaves the following tap unresolved — always prefer the `scroll-to` rewrite.) Keep a `gesture-swipe` as a raw `tool:` step when it isn't scrolling toward a specific element — especially a velocity-dependent gesture like swipe-to-dismiss, edge-swipe-back, or swipe-to-reveal a row action, which a momentum-free `scroll-to` would not reproduce.
132
+ - `tool: gesture-pinch` → `pinch: { on: "<target>", scale: … }`, deriving `scale` as `endDistance / startDistance`. Set `on:` to the element under the pinch center when the pinch was aimed at one (the map or image being zoomed); omit it for a screen-center pinch. Don't carry the recorded distances/angle over — the directive re-derives the geometry (finger placement, system-edge avoidance, chaining of large scales) at run time, so the conversion swaps device-specific coordinates for a portable selector with auto-wait. Keep the raw `tool: gesture-pinch` step when the pinch is anchored at a specific point _inside_ a large element (zooming toward a particular map location, not the map's center) or deliberately pans via `endCenterX`/`endCenterY` — `on:` takes only a selector and re-centers the pinch on the element's frame center, so converting would silently move the zoom anchor.
131
133
 
132
- Every other recorded tool (`gesture-swipe`, `gesture-scroll`, `button`, `screenshot`, …) has no directive form — leave it as a `tool:` step. The recorder already handles the rest: coordinate `gesture-tap`s are captured as portable `tap:` selector steps, a `restart-app` is captured as a `launch:` step, a `flow-execute` of a sibling fragment is captured as a `run: <name>` composition directive, and device ids are stripped. Captured selectors are emitted in the strict map form (`tap: { text: General }`), never as a loose bare string — the recorder verified the exact element the tap hit, and a bare string would re-parse as loose and route through the identifier-first fallback it was never checked against. After editing, re-run with `flow-execute` to confirm the cleaned flow still passes.
134
+ Every other recorded tool (a velocity-dependent `gesture-swipe`, a fixed-distance `gesture-scroll` not aimed at an element, `button`, `screenshot`, …) has no directive form — leave it as a `tool:` step. The recorder already handles the rest: coordinate `gesture-tap`s are captured as portable `tap:` selector steps, a `restart-app` is captured as a `launch:` step, a `flow-execute` of a sibling fragment is captured as a `run: <name>` composition directive, and device ids are stripped. Captured selectors are emitted in the strict map form (`tap: { text: General }`), never as a loose bare string — the recorder verified the exact element the tap hit, and a bare string would re-parse as loose and route through the identifier-first fallback it was never checked against. After editing, re-run with `flow-execute` to confirm the cleaned flow still passes.
133
135
 
134
136
  ### Example session
135
137
 
@@ -134,7 +134,7 @@ Swipe **up** (`fromY > toY`) = scroll content **down**. Default duration: 300ms.
134
134
  { "udid": "<UDID>", "centerX": 0.5, "centerY": 0.5, "startDistance": 0.2, "endDistance": 0.6 }
135
135
  ```
136
136
 
137
- All values are normalized 0.0–1.0 (fractions of screen, not pixels) — same as all other gesture tools. `startDistance: 0.2` means fingers start 20% of the screen apart; `endDistance: 0.6` means they end 60% apart. `startDistance < endDistance` = pinch out (zoom in). `startDistance > endDistance` = pinch in (zoom out). Defaults: `angle: 0` (horizontal), `durationMs: 300`. Optional: `"angle": 90` for vertical axis, `"durationMs": 500` for slower pinch.
137
+ All values are normalized 0.0–1.0 (fractions of screen, not pixels) — same as all other gesture tools. `startDistance: 0.2` means fingers start 20% of the screen apart; `endDistance: 0.6` means they end 60% apart. `startDistance < endDistance` = pinch out (zoom in). `startDistance > endDistance` = pinch in (zoom out). Defaults: `angle: 0` (horizontal), `durationMs: 300`. Optional: `"angle": 90` for vertical axis, `"durationMs": 500` for slower pinch, `"endCenterX"`/`"endCenterY"` to let the centroid drift to a new center over the gesture (omitted = fixed center).
138
138
 
139
139
  ### gesture-rotate — Two-finger rotation
140
140