waygraph 0.13.2 → 0.13.4

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.
Files changed (24) hide show
  1. package/dist/cli.js +126 -14
  2. package/dist/highlights.d.ts +25 -2
  3. package/dist/highlights.js +131 -43
  4. package/dist/index.d.ts +2 -2
  5. package/dist/index.js +1 -1
  6. package/examples/saucedemo/.tmp/device-demo/device-desktop.png +0 -0
  7. package/examples/saucedemo/.tmp/device-demo/device-mobile.png +0 -0
  8. package/examples/saucedemo/.tmp/device-demo/device-tablet.png +0 -0
  9. package/examples/saucedemo/.tmp/device-demo/orientation-gestures.webm +0 -0
  10. package/examples/saucedemo/.tmp/device-demo/page@fe3808e393870b60560762cb58982a73.webm +0 -0
  11. package/examples/saucedemo/.tmp/device-showcase/device-showcase.webm +0 -0
  12. package/examples/saucedemo/.tmp/device-showcase/page@10a295d8efbf9eae139eb931ef00363c.webm +0 -0
  13. package/examples/saucedemo/README.md +2 -0
  14. package/examples/saucedemo/package.json +2 -0
  15. package/examples/saucedemo/scripts/demo-orientation-gestures-video.mjs +262 -0
  16. package/examples/saucedemo/src/blocks/saucedemo-web/cart/nav-cart.block.ts +7 -2
  17. package/examples/saucedemo/src/blocks/saucedemo-web/inventory/methods/add-to-cart.effect.block.ts +4 -3
  18. package/examples/saucedemo/src/blocks/saucedemo-web/methods/submit-login.method.block.ts +6 -3
  19. package/examples/saucedemo/src/flows/device-showcase.flow.ts +28 -0
  20. package/package.json +1 -1
  21. package/templates/quickstart/src/blocks/saucedemo-web/cart/nav-cart.block.ts +10 -4
  22. package/templates/quickstart/src/blocks/saucedemo-web/inventory/methods/add-to-cart.effect.block.ts +7 -5
  23. package/templates/quickstart/src/blocks/saucedemo-web/methods/submit-login.method.block.ts +6 -3
  24. package/templates/quickstart/src/flows/device-showcase.flow.ts +28 -0
package/dist/cli.js CHANGED
@@ -1355,7 +1355,7 @@ async function installOverlay(page, title) {
1355
1355
  cursor.innerHTML = MOUSE_SVG;
1356
1356
  }
1357
1357
  };
1358
- const ensureChip = (preset, touch, remain) => {
1358
+ const ensureChip = (preset, touch, remain, orientation) => {
1359
1359
  let badge = document.getElementById("wg-device-badge");
1360
1360
  if (preset === "desktop" && !touch) {
1361
1361
  if (badge) {
@@ -1375,10 +1375,17 @@ async function installOverlay(page, title) {
1375
1375
  }
1376
1376
  badge.dataset.preset = preset;
1377
1377
  badge.dataset.touch = touch ? "1" : "0";
1378
+ const orient =
1379
+ orientation === "landscape" || orientation === "portrait"
1380
+ ? orientation
1381
+ : "";
1382
+ if (orient) badge.dataset.orientation = orient;
1378
1383
  const chipLabel =
1379
1384
  preset === "desktop" && !touch
1380
1385
  ? "desktop"
1381
- : preset + (remain ? " · remain" : "");
1386
+ : preset +
1387
+ (orient ? " \u00b7 " + (orient === "landscape" ? "land" : "port") : "") +
1388
+ (remain ? " \u00b7 remain" : "");
1382
1389
  badge.innerHTML =
1383
1390
  '<span class="wg-dev-icon">' +
1384
1391
  (ICONS[preset] || ICONS.desktop) +
@@ -1388,7 +1395,7 @@ async function installOverlay(page, title) {
1388
1395
  void badge.offsetWidth;
1389
1396
  badge.classList.add("wg-in");
1390
1397
  };
1391
- const showToast = (preset, touch, title, sub) => {
1398
+ const showToast = (preset, touch, title, sub, orientation) => {
1392
1399
  let toast = document.getElementById("wg-device-toast");
1393
1400
  if (toast && toast._wgTimer) {
1394
1401
  clearTimeout(toast._wgTimer);
@@ -1422,7 +1429,7 @@ async function installOverlay(page, title) {
1422
1429
  const t = document.getElementById("wg-device-toast");
1423
1430
  if (t) t.remove();
1424
1431
  if (preset !== "desktop" || touch) {
1425
- ensureChip(preset, touch, true);
1432
+ ensureChip(preset, touch, true, orientation);
1426
1433
  }
1427
1434
  }, 380);
1428
1435
  }, 2200);
@@ -1443,6 +1450,7 @@ async function installOverlay(page, title) {
1443
1450
  false,
1444
1451
  "Back to desktop",
1445
1452
  vp.width + "\u00d7" + vp.height + " \u00b7 mouse",
1453
+ "landscape",
1446
1454
  );
1447
1455
  setTimeout(() => {
1448
1456
  const badge = document.getElementById("wg-device-badge");
@@ -1455,6 +1463,7 @@ async function installOverlay(page, title) {
1455
1463
  if (toast) toast.remove();
1456
1464
  }
1457
1465
  window.__wgDevicePreset = "desktop";
1466
+ window.__wgDeviceOrientation = "landscape";
1458
1467
  return;
1459
1468
  }
1460
1469
 
@@ -1462,27 +1471,46 @@ async function installOverlay(page, title) {
1462
1471
  const preset = device.preset || "desktop";
1463
1472
  const touch = !!device.touchMode;
1464
1473
  const remain = payload.remain !== false && sync !== "clear";
1474
+ const vp = device.viewport || {};
1475
+ const orient =
1476
+ device.orientation === "landscape" || device.orientation === "portrait"
1477
+ ? device.orientation
1478
+ : vp.height > vp.width
1479
+ ? "portrait"
1480
+ : "landscape";
1465
1481
  const prev = window.__wgDevicePreset || "";
1466
- const changed = prev !== preset || (touch && prev === "desktop");
1482
+ const prevOrient = window.__wgDeviceOrientation || "";
1483
+ const presetChanged = prev !== preset || (touch && prev === "desktop");
1484
+ const orientChanged = prevOrient !== "" && prevOrient !== orient;
1485
+ const changed = presetChanged || orientChanged || prev === "";
1467
1486
  setCursor(touch);
1468
1487
  window.__wgDevicePreset = preset;
1488
+ window.__wgDeviceOrientation = orient;
1469
1489
 
1470
1490
  const announce =
1471
1491
  payload.announce === true ||
1472
1492
  (payload.announce !== false && (sync === "set" || sync === "clear") && changed);
1473
1493
 
1474
- const vp = device.viewport || {};
1475
1494
  const size =
1476
- (vp.width || "?") + "\u00d7" + (vp.height || "?") + (touch ? " \u00b7 touch" : "");
1477
- const titles = {
1495
+ (vp.width || "?") +
1496
+ "\u00d7" +
1497
+ (vp.height || "?") +
1498
+ " \u00b7 " +
1499
+ orient +
1500
+ (touch ? " \u00b7 touch" : "");
1501
+ let titleText = {
1478
1502
  mobile: "Now in mobile mode",
1479
1503
  tablet: "Now in tablet mode",
1480
1504
  desktop: "Now in desktop mode",
1481
- };
1505
+ }[preset] || "Device updated";
1506
+ if (orientChanged && !presetChanged) {
1507
+ titleText =
1508
+ orient === "landscape" ? "Rotated to landscape" : "Rotated to portrait";
1509
+ }
1482
1510
  if (announce) {
1483
- showToast(preset, touch, titles[preset] || "Device updated", size);
1511
+ showToast(preset, touch, titleText, size, orient);
1484
1512
  } else {
1485
- ensureChip(preset, touch, remain);
1513
+ ensureChip(preset, touch, remain, orient);
1486
1514
  }
1487
1515
  };
1488
1516
  },
@@ -2914,9 +2942,73 @@ async function clickPulseAt(page, x, y, tone) {
2914
2942
  .catch(() => {});
2915
2943
  }
2916
2944
 
2945
+ /**
2946
+ * Fit + center the OS browser window on the device viewport so mobile/tablet
2947
+ * is not stuck top-left inside a maximized frame (Dan 0.13.4).
2948
+ * Desktop clear restores maximized.
2949
+ */
2950
+ async function fitWindowToDeviceViewport(page, viewport, opts) {
2951
+ const maximize = !!(opts && opts.maximize);
2952
+ let client;
2953
+ try {
2954
+ client = await page.context().newCDPSession(page);
2955
+ const { windowId } = await client.send("Browser.getWindowForTarget");
2956
+ if (maximize) {
2957
+ await client.send("Browser.setWindowBounds", {
2958
+ windowId,
2959
+ bounds: { windowState: "maximized" },
2960
+ });
2961
+ return;
2962
+ }
2963
+ if (!viewport || !viewport.width || !viewport.height) return;
2964
+ const metrics = await page
2965
+ .evaluate(() => {
2966
+ const chromeW = Math.max(0, (window.outerWidth || 0) - (window.innerWidth || 0));
2967
+ const chromeH = Math.max(0, (window.outerHeight || 0) - (window.innerHeight || 0));
2968
+ return {
2969
+ chromeW: Number.isFinite(chromeW) ? chromeW : 0,
2970
+ // First paint after maximize often reports 0 chrome - use a floor.
2971
+ chromeH: chromeH > 20 ? chromeH : 88,
2972
+ screenW: window.screen.availWidth || 1920,
2973
+ screenH: window.screen.availHeight || 1080,
2974
+ };
2975
+ })
2976
+ .catch(() => ({ chromeW: 0, chromeH: 88, screenW: 1920, screenH: 1080 }));
2977
+ // Leave maximized before setting pixel bounds (Chromium ignores size while max).
2978
+ await client.send("Browser.setWindowBounds", {
2979
+ windowId,
2980
+ bounds: { windowState: "normal" },
2981
+ });
2982
+ const winW = Math.min(
2983
+ metrics.screenW,
2984
+ Math.max(320, Math.floor(viewport.width + metrics.chromeW)),
2985
+ );
2986
+ const winH = Math.min(
2987
+ metrics.screenH,
2988
+ Math.max(320, Math.floor(viewport.height + metrics.chromeH)),
2989
+ );
2990
+ const left = Math.max(0, Math.floor((metrics.screenW - winW) / 2));
2991
+ const top = Math.max(0, Math.floor((metrics.screenH - winH) / 2));
2992
+ await client.send("Browser.setWindowBounds", {
2993
+ windowId,
2994
+ bounds: {
2995
+ windowState: "normal",
2996
+ left,
2997
+ top,
2998
+ width: winW,
2999
+ height: winH,
3000
+ },
3001
+ });
3002
+ } catch {
3003
+ /* headless / no window - ignore */
3004
+ } finally {
3005
+ if (client) await client.detach().catch(() => {});
3006
+ }
3007
+ }
3008
+
2917
3009
  /**
2918
3010
  * Apply authored device fixture to the live page (0.13+).
2919
- * Seamless viewport lerp + toast on set/clear (quiet chip recreate on keep).
3011
+ * Seamless viewport lerp + centered window + toast on set/clear.
2920
3012
  */
2921
3013
  async function applyDeviceToPage(page, device, sync) {
2922
3014
  const mode = sync || (device ? "set" : "keep");
@@ -2945,6 +3037,8 @@ async function applyDeviceToPage(page, device, sync) {
2945
3037
  from = null;
2946
3038
  }
2947
3039
  const announce = mode === "set" || mode === "clear";
3040
+ const toDesktop =
3041
+ mode === "clear" || (d.preset === "desktop" && !d.touchMode);
2948
3042
  const shouldAnimate =
2949
3043
  announce &&
2950
3044
  from &&
@@ -2952,6 +3046,14 @@ async function applyDeviceToPage(page, device, sync) {
2952
3046
  from.height > 0 &&
2953
3047
  (from.width !== target.width || from.height !== target.height);
2954
3048
 
3049
+ // Unmaximize + center early so the lerp does not sit in the corner of a
3050
+ // maximized frame. Desktop clear maximizes at the end instead.
3051
+ if (!toDesktop) {
3052
+ await fitWindowToDeviceViewport(page, from && from.width ? from : target, {
3053
+ maximize: false,
3054
+ });
3055
+ }
3056
+
2955
3057
  try {
2956
3058
  if (shouldAnimate) {
2957
3059
  const steps = 14;
@@ -2960,10 +3062,14 @@ async function applyDeviceToPage(page, device, sync) {
2960
3062
  for (let i = 1; i <= steps; i++) {
2961
3063
  const t = i / steps;
2962
3064
  const e = t * t * (3 - 2 * t); // smoothstep
2963
- await realSet({
3065
+ const mid = {
2964
3066
  width: Math.round(from.width + (target.width - from.width) * e),
2965
3067
  height: Math.round(from.height + (target.height - from.height) * e),
2966
- });
3068
+ };
3069
+ await realSet(mid);
3070
+ if (!toDesktop && (i === 1 || i === steps || i % 3 === 0)) {
3071
+ await fitWindowToDeviceViewport(page, mid, { maximize: false });
3072
+ }
2967
3073
  if (i < steps) await new Promise((r) => setTimeout(r, stepMs));
2968
3074
  }
2969
3075
  } else {
@@ -2973,6 +3079,12 @@ async function applyDeviceToPage(page, device, sync) {
2973
3079
  /* maximized / null viewport hosts may reject - toast still updates */
2974
3080
  }
2975
3081
 
3082
+ if (toDesktop) {
3083
+ await fitWindowToDeviceViewport(page, target, { maximize: true });
3084
+ } else {
3085
+ await fitWindowToDeviceViewport(page, target, { maximize: false });
3086
+ }
3087
+
2976
3088
  // Fire toast near the end of the lerp so it reads as one seamless beat.
2977
3089
  await page
2978
3090
  .evaluate(
@@ -127,6 +127,8 @@ export type StubPhaseFixtures = {
127
127
  viewport?: DeviceViewport;
128
128
  /** Touch theater + Playwright touchscreen when true. */
129
129
  touch?: boolean;
130
+ /** Portrait / landscape (swaps preset width/height). */
131
+ orientation?: DeviceOrientation;
130
132
  };
131
133
  /** Named viewport presets (0.13+). */
132
134
  export type DevicePreset = "mobile" | "tablet" | "desktop";
@@ -149,14 +151,26 @@ export type DeviceState = {
149
151
  hasTouch: boolean;
150
152
  /** Demo gesture theater: finger cursor, tap/hold timing, prefer touchscreen. */
151
153
  touchMode: boolean;
154
+ /** Viewport orientation (0.13.3+). Default derived from width/height. */
155
+ orientation?: DeviceOrientation;
152
156
  };
153
- /** Built-in preset sizes (CSS pixels). */
157
+ /** Portrait (tall) or landscape (wide). */
158
+ export type DeviceOrientation = "portrait" | "landscape";
159
+ /** Built-in preset sizes (CSS pixels) — stored in portrait for mobile/tablet. */
154
160
  export declare const DEVICE_PRESETS: Record<DevicePreset, Omit<DeviceState, "touchMode"> & {
155
161
  touchMode?: boolean;
156
162
  }>;
157
163
  export declare function normalizeDevicePreset(raw: unknown): DevicePreset | undefined;
164
+ export declare function normalizeDeviceOrientation(raw: unknown): DeviceOrientation | undefined;
165
+ /** Infer orientation from box (square counts as landscape). */
166
+ export declare function orientationFromViewport(vp: DeviceViewport): DeviceOrientation;
167
+ /**
168
+ * Swap width/height so the viewport matches the requested orientation.
169
+ * Preserves deviceScaleFactor. No-op when already oriented.
170
+ */
171
+ export declare function applyOrientation(state: DeviceState, orientation: DeviceOrientation): DeviceState;
158
172
  /** Resolve a preset name or partial state into a full DeviceState. */
159
- export declare function resolveDeviceState(input: DevicePreset | DeviceState | DeviceViewport | undefined, touchOverride?: boolean): DeviceState | undefined;
173
+ export declare function resolveDeviceState(input: DevicePreset | DeviceState | DeviceViewport | undefined, touchOverride?: boolean, orientationOverride?: DeviceOrientation): DeviceState | undefined;
160
174
  /**
161
175
  * Carry-forward for device fixtures (mirror {@link applyTodoPhase}).
162
176
  * - clear: hideDevice / clearDevice / device("desktop") with clear intent
@@ -307,6 +321,15 @@ export type StubCtx<Out extends Checkpoint<string> = Checkpoint<string>> = {
307
321
  viewport(box: DeviceViewport): void;
308
322
  /** Enable / disable touch theater + touchscreen gestures. */
309
323
  touch(on?: boolean): void;
324
+ /**
325
+ * Rotate viewport to portrait or landscape (0.13.3+).
326
+ * Keeps current preset; swaps width/height. Toast + lerp like device().
327
+ */
328
+ orientation(o: DeviceOrientation): void;
329
+ /** Alias for {@link StubCtx.orientation}("landscape"). */
330
+ landscape(): void;
331
+ /** Alias for {@link StubCtx.orientation}("portrait"). */
332
+ portrait(): void;
310
333
  /**
311
334
  * Reset to desktop viewport and clear touch mode. Omit device on a later
312
335
  * block = **keep**; only hideDevice / clearDevice / device("desktop") clears.
@@ -12,7 +12,7 @@
12
12
  * All fixture shapes share optional {@link FixtureDurationFields} so demo dwell
13
13
  * before advancing is authored the same way on stubs, flow fixtures, and slides.
14
14
  */
15
- /** Built-in preset sizes (CSS pixels). */
15
+ /** Built-in preset sizes (CSS pixels) — stored in portrait for mobile/tablet. */
16
16
  export const DEVICE_PRESETS = {
17
17
  mobile: {
18
18
  preset: "mobile",
@@ -20,6 +20,7 @@ export const DEVICE_PRESETS = {
20
20
  isMobile: true,
21
21
  hasTouch: true,
22
22
  touchMode: true,
23
+ orientation: "portrait",
23
24
  },
24
25
  tablet: {
25
26
  preset: "tablet",
@@ -27,6 +28,7 @@ export const DEVICE_PRESETS = {
27
28
  isMobile: true,
28
29
  hasTouch: true,
29
30
  touchMode: true,
31
+ orientation: "portrait",
30
32
  },
31
33
  desktop: {
32
34
  preset: "desktop",
@@ -34,6 +36,7 @@ export const DEVICE_PRESETS = {
34
36
  isMobile: false,
35
37
  hasTouch: false,
36
38
  touchMode: false,
39
+ orientation: "landscape",
37
40
  },
38
41
  };
39
42
  export function normalizeDevicePreset(raw) {
@@ -48,67 +51,118 @@ export function normalizeDevicePreset(raw) {
48
51
  return "desktop";
49
52
  return undefined;
50
53
  }
54
+ export function normalizeDeviceOrientation(raw) {
55
+ const s = String(raw ?? "")
56
+ .trim()
57
+ .toLowerCase();
58
+ if (s === "portrait" || s === "port" || s === "tall" || s === "p")
59
+ return "portrait";
60
+ if (s === "landscape" || s === "land" || s === "wide" || s === "l")
61
+ return "landscape";
62
+ return undefined;
63
+ }
64
+ /** Infer orientation from box (square counts as landscape). */
65
+ export function orientationFromViewport(vp) {
66
+ return vp.height > vp.width ? "portrait" : "landscape";
67
+ }
68
+ /**
69
+ * Swap width/height so the viewport matches the requested orientation.
70
+ * Preserves deviceScaleFactor. No-op when already oriented.
71
+ */
72
+ export function applyOrientation(state, orientation) {
73
+ const vp = { ...state.viewport };
74
+ const current = orientationFromViewport(vp);
75
+ if (current !== orientation) {
76
+ const w = vp.width;
77
+ vp.width = vp.height;
78
+ vp.height = w;
79
+ }
80
+ return { ...state, viewport: vp, orientation };
81
+ }
51
82
  /** Resolve a preset name or partial state into a full DeviceState. */
52
- export function resolveDeviceState(input, touchOverride) {
83
+ export function resolveDeviceState(input, touchOverride, orientationOverride) {
53
84
  if (input === undefined)
54
85
  return undefined;
86
+ let resolved;
55
87
  if (typeof input === "string") {
56
88
  const p = normalizeDevicePreset(input);
57
89
  if (!p)
58
90
  return undefined;
59
91
  const base = DEVICE_PRESETS[p];
60
92
  const touchMode = touchOverride !== undefined ? !!touchOverride : !!base.touchMode;
61
- return {
93
+ resolved = {
62
94
  preset: base.preset,
63
95
  viewport: { ...base.viewport },
64
96
  isMobile: base.isMobile,
65
97
  hasTouch: base.hasTouch || touchMode,
66
98
  touchMode,
99
+ orientation: base.orientation || orientationFromViewport(base.viewport),
67
100
  };
68
101
  }
69
- if ("width" in input && "height" in input && !("preset" in input)) {
102
+ else if ("width" in input && "height" in input && !("preset" in input)) {
70
103
  const vp = input;
71
104
  const touchMode = touchOverride !== undefined ? !!touchOverride : true;
72
- return {
105
+ const box = {
106
+ width: Math.max(200, Math.floor(vp.width)),
107
+ height: Math.max(200, Math.floor(vp.height)),
108
+ ...(vp.deviceScaleFactor !== undefined
109
+ ? { deviceScaleFactor: vp.deviceScaleFactor }
110
+ : { deviceScaleFactor: 2 }),
111
+ };
112
+ resolved = {
73
113
  preset: "mobile",
74
- viewport: {
75
- width: Math.max(200, Math.floor(vp.width)),
76
- height: Math.max(200, Math.floor(vp.height)),
77
- ...(vp.deviceScaleFactor !== undefined
78
- ? { deviceScaleFactor: vp.deviceScaleFactor }
79
- : { deviceScaleFactor: 2 }),
80
- },
114
+ viewport: box,
81
115
  isMobile: true,
82
116
  hasTouch: true,
83
117
  touchMode,
118
+ orientation: orientationFromViewport(box),
84
119
  };
85
120
  }
86
- const d = input;
87
- const preset = normalizeDevicePreset(d.preset) || "desktop";
88
- const base = DEVICE_PRESETS[preset];
89
- const touchMode = touchOverride !== undefined
90
- ? !!touchOverride
91
- : d.touchMode !== undefined
92
- ? !!d.touchMode
93
- : !!base.touchMode;
94
- const viewport = d.viewport
95
- ? {
96
- width: Math.max(200, Math.floor(d.viewport.width)),
97
- height: Math.max(200, Math.floor(d.viewport.height)),
98
- ...(d.viewport.deviceScaleFactor !== undefined
99
- ? { deviceScaleFactor: d.viewport.deviceScaleFactor }
100
- : base.viewport.deviceScaleFactor !== undefined
101
- ? { deviceScaleFactor: base.viewport.deviceScaleFactor }
102
- : {}),
103
- }
104
- : { ...base.viewport };
105
- return {
106
- preset,
107
- viewport,
108
- isMobile: d.isMobile !== undefined ? !!d.isMobile : base.isMobile,
109
- hasTouch: d.hasTouch !== undefined ? !!d.hasTouch : base.hasTouch || touchMode,
110
- touchMode,
111
- };
121
+ else {
122
+ const d = input;
123
+ const preset = normalizeDevicePreset(d.preset) || "desktop";
124
+ const base = DEVICE_PRESETS[preset];
125
+ const touchMode = touchOverride !== undefined
126
+ ? !!touchOverride
127
+ : d.touchMode !== undefined
128
+ ? !!d.touchMode
129
+ : !!base.touchMode;
130
+ const viewport = d.viewport
131
+ ? {
132
+ width: Math.max(200, Math.floor(d.viewport.width)),
133
+ height: Math.max(200, Math.floor(d.viewport.height)),
134
+ ...(d.viewport.deviceScaleFactor !== undefined
135
+ ? { deviceScaleFactor: d.viewport.deviceScaleFactor }
136
+ : base.viewport.deviceScaleFactor !== undefined
137
+ ? { deviceScaleFactor: base.viewport.deviceScaleFactor }
138
+ : {}),
139
+ }
140
+ : { ...base.viewport };
141
+ resolved = {
142
+ preset,
143
+ viewport,
144
+ isMobile: d.isMobile !== undefined ? !!d.isMobile : base.isMobile,
145
+ hasTouch: d.hasTouch !== undefined ? !!d.hasTouch : base.hasTouch || touchMode,
146
+ touchMode,
147
+ orientation: normalizeDeviceOrientation(d.orientation) ||
148
+ orientationFromViewport(viewport),
149
+ };
150
+ }
151
+ if (!resolved)
152
+ return undefined;
153
+ const want = orientationOverride ||
154
+ (typeof input === "object" && input && "orientation" in input
155
+ ? normalizeDeviceOrientation(input.orientation)
156
+ : undefined);
157
+ if (want)
158
+ return applyOrientation(resolved, want);
159
+ if (!resolved.orientation) {
160
+ resolved = {
161
+ ...resolved,
162
+ orientation: orientationFromViewport(resolved.viewport),
163
+ };
164
+ }
165
+ return resolved;
112
166
  }
113
167
  /**
114
168
  * Carry-forward for device fixtures (mirror {@link applyTodoPhase}).
@@ -734,7 +788,7 @@ function createStubCtx(bag, opts) {
734
788
  device(preset) {
735
789
  bag.deviceTouched = true;
736
790
  bag.hideDevice = false;
737
- const resolved = resolveDeviceState(preset, bag.touchOverride);
791
+ const resolved = resolveDeviceState(preset, bag.touchOverride, bag.orientationOverride);
738
792
  if (resolved) {
739
793
  bag.device = resolved;
740
794
  if (resolved.preset === "desktop" && !resolved.touchMode) {
@@ -745,7 +799,7 @@ function createStubCtx(bag, opts) {
745
799
  viewport(box) {
746
800
  bag.deviceTouched = true;
747
801
  bag.hideDevice = false;
748
- const resolved = resolveDeviceState(box, bag.touchOverride);
802
+ const resolved = resolveDeviceState(box, bag.touchOverride, bag.orientationOverride);
749
803
  if (resolved)
750
804
  bag.device = resolved;
751
805
  },
@@ -765,22 +819,43 @@ function createStubCtx(bag, opts) {
765
819
  bag.hideDevice = false;
766
820
  }
767
821
  else if (enabled) {
768
- const base = resolveDeviceState("desktop", true);
822
+ const base = resolveDeviceState("desktop", true, bag.orientationOverride);
769
823
  if (base)
770
824
  bag.device = { ...base, touchMode: true, hasTouch: true };
771
825
  }
772
826
  },
827
+ orientation(o) {
828
+ const want = normalizeDeviceOrientation(o);
829
+ if (!want)
830
+ return;
831
+ bag.deviceTouched = true;
832
+ bag.hideDevice = false;
833
+ bag.orientationOverride = want;
834
+ const base = bag.device ||
835
+ resolveDeviceState("mobile", bag.touchOverride) ||
836
+ resolveDeviceState("mobile");
837
+ if (base)
838
+ bag.device = applyOrientation(base, want);
839
+ },
840
+ landscape() {
841
+ this.orientation("landscape");
842
+ },
843
+ portrait() {
844
+ this.orientation("portrait");
845
+ },
773
846
  hideDevice() {
774
847
  bag.deviceTouched = true;
775
848
  bag.hideDevice = true;
776
849
  bag.device = resolveDeviceState("desktop", false);
777
850
  bag.touchOverride = false;
851
+ bag.orientationOverride = undefined;
778
852
  },
779
853
  clearDevice() {
780
854
  bag.deviceTouched = true;
781
855
  bag.hideDevice = true;
782
856
  bag.device = resolveDeviceState("desktop", false);
783
857
  bag.touchOverride = false;
858
+ bag.orientationOverride = undefined;
784
859
  },
785
860
  zoom(n) {
786
861
  if (Number.isFinite(n) && n > 0)
@@ -834,10 +909,23 @@ function createStubCtx(bag, opts) {
834
909
  bag.deviceTouched = true;
835
910
  bag.touchOverride = !!partial.touch;
836
911
  }
912
+ if (partial.orientation !== undefined) {
913
+ const want = normalizeDeviceOrientation(partial.orientation);
914
+ if (want) {
915
+ bag.deviceTouched = true;
916
+ bag.hideDevice = false;
917
+ bag.orientationOverride = want;
918
+ const base = bag.device ||
919
+ resolveDeviceState("mobile", bag.touchOverride) ||
920
+ resolveDeviceState("mobile");
921
+ if (base)
922
+ bag.device = applyOrientation(base, want);
923
+ }
924
+ }
837
925
  if (partial.device !== undefined) {
838
926
  bag.deviceTouched = true;
839
927
  bag.hideDevice = false;
840
- const resolved = resolveDeviceState(partial.device, bag.touchOverride);
928
+ const resolved = resolveDeviceState(partial.device, bag.touchOverride, bag.orientationOverride);
841
929
  if (resolved) {
842
930
  bag.device = resolved;
843
931
  if (resolved.preset === "desktop" && !resolved.touchMode)
@@ -847,7 +935,7 @@ function createStubCtx(bag, opts) {
847
935
  if (partial.viewport !== undefined) {
848
936
  bag.deviceTouched = true;
849
937
  bag.hideDevice = false;
850
- const resolved = resolveDeviceState(partial.viewport, bag.touchOverride);
938
+ const resolved = resolveDeviceState(partial.viewport, bag.touchOverride, bag.orientationOverride);
851
939
  if (resolved)
852
940
  bag.device = resolved;
853
941
  }
package/dist/index.d.ts CHANGED
@@ -3,8 +3,8 @@ export { connect, checkpoint } from "./types.js";
3
3
  export { MemKey, MemPage, key, keyGroup } from "./mem-page.js";
4
4
  export { Trait, urlMatches, textEquals, visible } from "./trait.js";
5
5
  export { runGraph, spawnTab, narrate, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, defineNavBlock, defineNavClickBlock, defineMethodBlock, defineActionBlock, definePageBlock, defineEffectBlock, defineMemEffectBlock, defineMemNavBlock, locate, composeBlock, fastForwardComposeBlock, isFastForwardBlock, preflight, withSessionReset, withTitle, withDemoPace, withBlockPace, withHighlightStyle, withExpectedFailure, withHighlightFixtures, chainFlow, } from "./engine.js";
6
- export { resolveHighlightSlots, resolveSlides, runStubPhase, resolveFixtureDwellMs, resolveSlideDwellMs, formatHighlightCaption, hasAuthoredStubAfter, hasAuthoredStubOnError, normalizeHighlightTone, normalizeDemoPace, normalizeHighlightSize, normalizeHighlightWeight, applyHighlightStyleDefaults, toneIconPrefix, resolveStepDemoPace, demoPaceScale, demoPaceIsBlitz, demoPaceIsFast, demoPaceIsSlow, demoPaceGateMs, formatDemoPaceBadge, formatDemoPaceLabel, normalizeTodos, normalizeTodoPos, normalizeTodoStyle, buildTodoDock, applyTodoPhase, advanceTodoDock, completeSequentialTodoDock, applyDevicePhase, resolveDeviceState, normalizeDevicePreset, DEVICE_PRESETS, FIXTURE_DURATION_MS_DEFAULT, FIXTURE_DURATION_FAST_MS_DEFAULT, FIXTURE_DURATION_SLOW_MS_DEFAULT, SLIDE_DURATION_MS_DEFAULT, SLIDE_DURATION_FAST_MS_DEFAULT, } from "./highlights.js";
7
- export type { WaygraphHighlightStub, WaygraphHighlightFixture, WaygraphSlide, WaygraphTodoItem, WaygraphTodoInput, WaygraphTodoGroup, WaygraphTodoGroupInput, TodoDockState, TodoListStyle, FixtureDurationFields, HighlightStubPhase, HighlightStubPhaseName, HighlightFixtureMap, ResolvedHighlight, WaygraphHighlight, HighlightTone, HighlightSize, HighlightWeight, HighlightStyleDefaults, DemoPace, DemoPaceName, StubCtx, StubLifecycleFn, StubPhaseResult, StubPhaseFixtures, DevicePreset, DeviceViewport, DeviceState, } from "./highlights.js";
6
+ export { resolveHighlightSlots, resolveSlides, runStubPhase, resolveFixtureDwellMs, resolveSlideDwellMs, formatHighlightCaption, hasAuthoredStubAfter, hasAuthoredStubOnError, normalizeHighlightTone, normalizeDemoPace, normalizeHighlightSize, normalizeHighlightWeight, applyHighlightStyleDefaults, toneIconPrefix, resolveStepDemoPace, demoPaceScale, demoPaceIsBlitz, demoPaceIsFast, demoPaceIsSlow, demoPaceGateMs, formatDemoPaceBadge, formatDemoPaceLabel, normalizeTodos, normalizeTodoPos, normalizeTodoStyle, buildTodoDock, applyTodoPhase, advanceTodoDock, completeSequentialTodoDock, applyDevicePhase, resolveDeviceState, normalizeDevicePreset, normalizeDeviceOrientation, applyOrientation, orientationFromViewport, DEVICE_PRESETS, FIXTURE_DURATION_MS_DEFAULT, FIXTURE_DURATION_FAST_MS_DEFAULT, FIXTURE_DURATION_SLOW_MS_DEFAULT, SLIDE_DURATION_MS_DEFAULT, SLIDE_DURATION_FAST_MS_DEFAULT, } from "./highlights.js";
7
+ export type { WaygraphHighlightStub, WaygraphHighlightFixture, WaygraphSlide, WaygraphTodoItem, WaygraphTodoInput, WaygraphTodoGroup, WaygraphTodoGroupInput, TodoDockState, TodoListStyle, FixtureDurationFields, HighlightStubPhase, HighlightStubPhaseName, HighlightFixtureMap, ResolvedHighlight, WaygraphHighlight, HighlightTone, HighlightSize, HighlightWeight, HighlightStyleDefaults, DemoPace, DemoPaceName, StubCtx, StubLifecycleFn, StubPhaseResult, StubPhaseFixtures, DevicePreset, DeviceViewport, DeviceState, DeviceOrientation, } from "./highlights.js";
8
8
  export type { Flow, EngineConfig, ComposedBlock, FastForwardComposedBlock, RunGraphOptions, BlockInfo, NavBlock, NavBlockOptions, NavClickBlock, NavClickBlockOptions, MethodBlock, ActionBlock, PageBlock, PageBlockOptions, PageMethodEntry, EffectBlock, MemEffectBlock, MemNavBlock, } from "./engine.js";
9
9
  export type { ActionPage } from "./types.js";
10
10
  export { discoverGraph, toMermaid, findOrphanBlocks, findBlockPath } from "./graph.js";
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ export { connect, checkpoint } from "./types.js";
2
2
  export { MemKey, MemPage, key, keyGroup } from "./mem-page.js";
3
3
  export { Trait, urlMatches, textEquals, visible } from "./trait.js";
4
4
  export { runGraph, spawnTab, narrate, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, defineNavBlock, defineNavClickBlock, defineMethodBlock, defineActionBlock, definePageBlock, defineEffectBlock, defineMemEffectBlock, defineMemNavBlock, locate, composeBlock, fastForwardComposeBlock, isFastForwardBlock, preflight, withSessionReset, withTitle, withDemoPace, withBlockPace, withHighlightStyle, withExpectedFailure, withHighlightFixtures, chainFlow, } from "./engine.js";
5
- export { resolveHighlightSlots, resolveSlides, runStubPhase, resolveFixtureDwellMs, resolveSlideDwellMs, formatHighlightCaption, hasAuthoredStubAfter, hasAuthoredStubOnError, normalizeHighlightTone, normalizeDemoPace, normalizeHighlightSize, normalizeHighlightWeight, applyHighlightStyleDefaults, toneIconPrefix, resolveStepDemoPace, demoPaceScale, demoPaceIsBlitz, demoPaceIsFast, demoPaceIsSlow, demoPaceGateMs, formatDemoPaceBadge, formatDemoPaceLabel, normalizeTodos, normalizeTodoPos, normalizeTodoStyle, buildTodoDock, applyTodoPhase, advanceTodoDock, completeSequentialTodoDock, applyDevicePhase, resolveDeviceState, normalizeDevicePreset, DEVICE_PRESETS, FIXTURE_DURATION_MS_DEFAULT, FIXTURE_DURATION_FAST_MS_DEFAULT, FIXTURE_DURATION_SLOW_MS_DEFAULT, SLIDE_DURATION_MS_DEFAULT, SLIDE_DURATION_FAST_MS_DEFAULT, } from "./highlights.js";
5
+ export { resolveHighlightSlots, resolveSlides, runStubPhase, resolveFixtureDwellMs, resolveSlideDwellMs, formatHighlightCaption, hasAuthoredStubAfter, hasAuthoredStubOnError, normalizeHighlightTone, normalizeDemoPace, normalizeHighlightSize, normalizeHighlightWeight, applyHighlightStyleDefaults, toneIconPrefix, resolveStepDemoPace, demoPaceScale, demoPaceIsBlitz, demoPaceIsFast, demoPaceIsSlow, demoPaceGateMs, formatDemoPaceBadge, formatDemoPaceLabel, normalizeTodos, normalizeTodoPos, normalizeTodoStyle, buildTodoDock, applyTodoPhase, advanceTodoDock, completeSequentialTodoDock, applyDevicePhase, resolveDeviceState, normalizeDevicePreset, normalizeDeviceOrientation, applyOrientation, orientationFromViewport, DEVICE_PRESETS, FIXTURE_DURATION_MS_DEFAULT, FIXTURE_DURATION_FAST_MS_DEFAULT, FIXTURE_DURATION_SLOW_MS_DEFAULT, SLIDE_DURATION_MS_DEFAULT, SLIDE_DURATION_FAST_MS_DEFAULT, } from "./highlights.js";
6
6
  export { discoverGraph, toMermaid, findOrphanBlocks, findBlockPath } from "./graph.js";
7
7
  export { isFileSelectToken, parseBlocksSelect, matchBlocksSelect, globToRegExp, } from "./blocks-select.js";
8
8
  export { EdgeLeaseCoordinator } from "./traverse-lease.js";
@@ -39,6 +39,8 @@ npx playwright install chromium
39
39
  | Coverage JSON + min gate (10%) | `npm run traverse:coverage` |
40
40
  | Shop demo stepper | `npm run demo:step` |
41
41
  | Shop demo autoplay | `npm run demo:autoplay` |
42
+ | **Device showcase** (mobile/tablet + portrait/landscape) | `npm run demo:device` |
43
+ | Device showcase + `.webm` | `npm run demo:device:video` |
42
44
  | **Cart bulk** demo (add-all → remove-all) | `npm run demo:bulk` |
43
45
  | Cart bulk stepper | `npm run demo:bulk:step` |
44
46
  | List flows | `npm run flows` |
@@ -25,6 +25,8 @@
25
25
  "prove:add-to-cart-menu": "node scripts/prove-add-to-cart-menu.mjs",
26
26
  "prove": "npm run prove:login-branch && npm run prove:graph && npm run prove:add-to-cart-menu && npm test",
27
27
  "record": "waygraph run --blocks \"loginFlow then shopFlow({\\\"saucedemo.selectedItem\\\":{\\\"id\\\":\\\"sauce-labs-backpack\\\",\\\"name\\\":\\\"Sauce Labs Backpack\\\"}})\" --data \"{\\\"saucedemo.credentials\\\":{\\\"username\\\":\\\"standard_user\\\",\\\"password\\\":\\\"secret_sauce\\\"}}\" --video --no-step",
28
+ "demo:device": "waygraph demo --blocks deviceShowcaseFlow --auto-next --title \"Device showcase\" --data \"{\\\"saucedemo.credentials\\\":{\\\"username\\\":\\\"standard_user\\\",\\\"password\\\":\\\"secret_sauce\\\"},\\\"saucedemo.selectedItem\\\":{\\\"id\\\":\\\"sauce-labs-backpack\\\",\\\"name\\\":\\\"Sauce Labs Backpack\\\"}}\"",
29
+ "demo:device:video": "waygraph demo --blocks deviceShowcaseFlow --auto-next --video ./.tmp/device-showcase --title \"Device showcase\" --data \"{\\\"saucedemo.credentials\\\":{\\\"username\\\":\\\"standard_user\\\",\\\"password\\\":\\\"secret_sauce\\\"},\\\"saucedemo.selectedItem\\\":{\\\"id\\\":\\\"sauce-labs-backpack\\\",\\\"name\\\":\\\"Sauce Labs Backpack\\\"}}\"",
28
30
  "demo:bulk": "waygraph demo --blocks cartBulkFlow --data \"{\\\"saucedemo.credentials\\\":{\\\"username\\\":\\\"standard_user\\\",\\\"password\\\":\\\"secret_sauce\\\"}}\"",
29
31
  "demo:bulk:auto-next": "waygraph demo --blocks cartBulkFlow --data \"{\\\"saucedemo.credentials\\\":{\\\"username\\\":\\\"standard_user\\\",\\\"password\\\":\\\"secret_sauce\\\"}}\" --auto-next --title \"Cart bulk demo\"",
30
32
  "demo:bulk:video": "waygraph demo --blocks cartBulkFlow --data \"{\\\"saucedemo.credentials\\\":{\\\"username\\\":\\\"standard_user\\\",\\\"password\\\":\\\"secret_sauce\\\"}}\" --auto-play-video --title \"Cart bulk demo\"",
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Record a .webm of: portrait/landscape + click / tap / hold theater.
3
+ * Square capture so orientation changes are obvious in the video frame.
4
+ *
5
+ * From examples/saucedemo:
6
+ * node scripts/demo-orientation-gestures-video.mjs
7
+ */
8
+ import { chromium } from "playwright";
9
+ import { mkdirSync, copyFileSync, readdirSync } from "node:fs";
10
+ import { join, dirname } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+ const outDir = join(__dirname, "../.tmp/device-demo");
15
+ mkdirSync(outDir, { recursive: true });
16
+
17
+ const CAPTURE = { width: 1080, height: 1080 };
18
+
19
+ const PRESETS = {
20
+ mobilePortrait: { w: 390, h: 844, preset: "mobile", orient: "portrait", touch: true },
21
+ mobileLandscape: { w: 844, h: 390, preset: "mobile", orient: "landscape", touch: true },
22
+ tabletPortrait: { w: 768, h: 1024, preset: "tablet", orient: "portrait", touch: true },
23
+ tabletLandscape: { w: 1024, h: 768, preset: "tablet", orient: "landscape", touch: true },
24
+ desktop: { w: 1280, h: 720, preset: "desktop", orient: "landscape", touch: false },
25
+ };
26
+
27
+ const CSS = `
28
+ html,body{margin:0;background:#0b1220;}
29
+ #wg-stage{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;
30
+ background:radial-gradient(ellipse at 50% 30%,#1a1030 0%,#0b1220 70%);}
31
+ #wg-frame{position:relative;background:#fff;overflow:hidden;box-shadow:0 20px 60px rgba(0,0,0,.55);
32
+ border:3px solid #2a1650;border-radius:18px;transition:width .5s cubic-bezier(.22,1,.36,1),
33
+ height .5s cubic-bezier(.22,1,.36,1),border-radius .4s ease;}
34
+ #wg-frame iframe{border:0;width:100%;height:100%;display:block;}
35
+ #wg-device-toast{position:fixed;z-index:2147483647;top:18px;right:18px;
36
+ display:flex;align-items:center;gap:10px;min-width:220px;max-width:340px;
37
+ padding:12px 14px;border-radius:14px;background:rgba(20,10,40,.96);color:#f0e8ff;
38
+ border:1px solid rgba(124,58,237,.55);box-shadow:0 10px 28px rgba(0,0,0,.45);
39
+ font:600 13px/1.35 system-ui,sans-serif;pointer-events:none;
40
+ opacity:0;transform:translateX(18px);transition:opacity .35s ease,transform .45s cubic-bezier(.22,1,.36,1);}
41
+ #wg-device-toast.wg-in{opacity:1;transform:translateX(0);}
42
+ #wg-device-toast[data-preset=mobile]{border-color:#3B82F6;}
43
+ #wg-device-toast[data-preset=tablet]{border-color:#22C55E;}
44
+ #wg-device-toast[data-preset=desktop]{border-color:#9CA3AF;}
45
+ #wg-device-toast .wg-dev-icon{width:36px;height:36px;border-radius:10px;display:flex;
46
+ align-items:center;justify-content:center;background:rgba(124,58,237,.28);}
47
+ #wg-device-toast .wg-dev-title{font:800 13px/1.2 system-ui,sans-serif;color:#fff;}
48
+ #wg-device-toast .wg-dev-sub{margin-top:3px;font:600 11px/1.3 system-ui,sans-serif;color:#c9a6ff;}
49
+ #wg-gesture-banner{position:fixed;z-index:2147483646;left:18px;bottom:18px;
50
+ padding:10px 14px;border-radius:12px;background:rgba(20,10,40,.94);color:#fff;
51
+ font:700 13px/1.3 system-ui,sans-serif;border:1px solid rgba(124,58,237,.45);}
52
+ #wg-cursor{position:fixed;z-index:2147483647;width:28px;height:28px;pointer-events:none;
53
+ left:0;top:0;opacity:0;margin:0;transition:transform .55s cubic-bezier(.22,1,.36,1),opacity .2s;}
54
+ #wg-click-pulse{position:fixed;z-index:2147483647;width:18px;height:18px;margin-left:-9px;
55
+ margin-top:-9px;border-radius:50%;pointer-events:none;opacity:0;border:2px solid #7C3AED;
56
+ background:rgba(124,58,237,.3);}
57
+ #wg-click-pulse.wg-pulse{animation:wg-pulse .55s ease-out;}
58
+ #wg-click-pulse[data-hold=1]{border-color:#EAB308;background:rgba(234,179,8,.35);
59
+ width:28px;height:28px;margin-left:-14px;margin-top:-14px;}
60
+ @keyframes wg-pulse{0%{opacity:.95;transform:scale(.35);}100%{opacity:0;transform:scale(2.6);}}
61
+ `;
62
+
63
+ const ICONS = {
64
+ mobile:
65
+ '<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="#fff" stroke-width="1.8"><rect x="7" y="2.5" width="10" height="19" rx="2.2"/><circle cx="12" cy="18.2" r="1.1" fill="#fff" stroke="none"/></svg>',
66
+ tablet:
67
+ '<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="#fff" stroke-width="1.8"><rect x="3.5" y="4" width="17" height="16" rx="2"/><circle cx="12" cy="17.2" r="1" fill="#fff" stroke="none"/></svg>',
68
+ desktop:
69
+ '<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="#fff" stroke-width="1.8"><rect x="2.5" y="3.5" width="19" height="12.5" rx="1.5"/><path d="M8 20h8M12 16v4"/></svg>',
70
+ };
71
+
72
+ const MOUSE =
73
+ "<svg viewBox='0 0 32 32' width='24' height='24'><path fill='#0C0C1A' stroke='#fff' stroke-width='1.4' d='M6 3.5l1.4 22.5 5.8-5.4 4.2 9.4 3.6-1.6-4.2-9.2H26z'/></svg>";
74
+ const FINGER =
75
+ "<svg viewBox='0 0 32 32' width='28' height='28'><ellipse cx='16' cy='22' rx='7' ry='8' fill='#0C0C1A' stroke='#fff' stroke-width='1.4'/><rect x='12' y='6' width='8' height='16' rx='4' fill='#0C0C1A' stroke='#fff' stroke-width='1.4'/></svg>";
76
+
77
+ const executablePath = process.env.CHROME_PATH || process.env.CHROMIUM_PATH || undefined;
78
+
79
+ const browser = await chromium.launch({
80
+ headless: false,
81
+ ...(executablePath ? { executablePath } : {}),
82
+ });
83
+ const context = await browser.newContext({
84
+ hasTouch: true,
85
+ viewport: CAPTURE,
86
+ recordVideo: { dir: outDir, size: CAPTURE },
87
+ deviceScaleFactor: 1,
88
+ });
89
+ const page = await context.newPage();
90
+
91
+ await page.setContent(`<!doctype html><html><head><style>${CSS}</style></head>
92
+ <body>
93
+ <div id="wg-stage"><div id="wg-frame" style="width:390px;height:844px">
94
+ <iframe id="wg-app" src="https://www.saucedemo.com/"></iframe>
95
+ </div></div>
96
+ <div id="wg-gesture-banner">waygraph · orientation + gestures</div>
97
+ <div id="wg-cursor">${FINGER}</div>
98
+ <div id="wg-click-pulse"></div>
99
+ </body></html>`);
100
+
101
+ await page.waitForTimeout(2500);
102
+
103
+ async function toast(title, sub, preset) {
104
+ await page.evaluate(
105
+ ({ title, sub, preset, icons }) => {
106
+ let t = document.getElementById("wg-device-toast");
107
+ if (!t) {
108
+ t = document.createElement("div");
109
+ t.id = "wg-device-toast";
110
+ document.body.appendChild(t);
111
+ }
112
+ t.dataset.preset = preset;
113
+ t.innerHTML =
114
+ '<span class="wg-dev-icon">' +
115
+ icons[preset] +
116
+ '</span><span><div class="wg-dev-title">' +
117
+ title +
118
+ '</div><div class="wg-dev-sub">' +
119
+ sub +
120
+ "</div></span>";
121
+ t.classList.remove("wg-in");
122
+ void t.offsetWidth;
123
+ t.classList.add("wg-in");
124
+ },
125
+ { title, sub, preset, icons: ICONS },
126
+ );
127
+ }
128
+
129
+ async function setFrame(key) {
130
+ const p = PRESETS[key];
131
+ const title =
132
+ key.includes("Landscape") || p.orient === "landscape"
133
+ ? p.preset === "desktop"
134
+ ? "Back to desktop"
135
+ : "Rotated to landscape"
136
+ : key.includes("Portrait") || p.orient === "portrait"
137
+ ? p.preset === "mobile" && key === "mobilePortrait"
138
+ ? "Now in mobile mode"
139
+ : "Rotated to portrait"
140
+ : "Device updated";
141
+ // First switch: mobile portrait uses "Now in mobile"
142
+ const t =
143
+ key === "mobilePortrait"
144
+ ? "Now in mobile mode"
145
+ : key === "desktop"
146
+ ? "Back to desktop"
147
+ : key.includes("Landscape")
148
+ ? "Rotated to landscape"
149
+ : key.includes("Portrait")
150
+ ? key.startsWith("tablet")
151
+ ? "Now in tablet mode"
152
+ : "Rotated to portrait"
153
+ : title;
154
+
155
+ await page.evaluate(
156
+ ({ w, h, touch, finger, mouse }) => {
157
+ const frame = document.getElementById("wg-frame");
158
+ frame.style.width = w + "px";
159
+ frame.style.height = h + "px";
160
+ frame.style.borderRadius = touch ? (w < h ? "28px" : "18px") : "10px";
161
+ const c = document.getElementById("wg-cursor");
162
+ c.innerHTML = touch ? finger : mouse;
163
+ },
164
+ { w: p.w, h: p.h, touch: p.touch, finger: FINGER, mouse: MOUSE },
165
+ );
166
+ await toast(t, `${p.w}×${p.h} · ${p.orient}${p.touch ? " · touch" : " · mouse"}`, p.preset);
167
+ await page.waitForTimeout(900);
168
+ }
169
+
170
+ async function banner(text) {
171
+ await page.evaluate((text) => {
172
+ const b = document.getElementById("wg-gesture-banner");
173
+ if (b) b.textContent = text;
174
+ }, text);
175
+ }
176
+
177
+ async function gestureAt(kind) {
178
+ // Aim at center of the device frame (login button-ish on saucedemo).
179
+ const box = await page.locator("#wg-frame").boundingBox();
180
+ if (!box) return;
181
+ const x = box.x + box.width * 0.5;
182
+ const y = box.y + box.height * 0.58;
183
+ await banner(
184
+ kind === "click"
185
+ ? "Gesture: CLICK (mouse)"
186
+ : kind === "tap"
187
+ ? "Gesture: TAP (touch)"
188
+ : "Gesture: HOLD (long-press)",
189
+ );
190
+ await page.evaluate(
191
+ ({ x, y, kind, ms }) => {
192
+ const c = document.getElementById("wg-cursor");
193
+ c.style.setProperty("--wg-cursor-ms", ms + "ms");
194
+ c.style.opacity = "1";
195
+ c.style.transform = `translate(${x - 8}px,${y - 8}px)`;
196
+ },
197
+ { x, y, kind, ms: 550 },
198
+ );
199
+ await page.waitForTimeout(600);
200
+ await page.evaluate(
201
+ ({ x, y, kind }) => {
202
+ const p = document.getElementById("wg-click-pulse");
203
+ p.style.left = x + "px";
204
+ p.style.top = y + "px";
205
+ p.dataset.hold = kind === "hold" ? "1" : "0";
206
+ p.classList.remove("wg-pulse");
207
+ void p.offsetWidth;
208
+ p.classList.add("wg-pulse");
209
+ },
210
+ { x, y, kind },
211
+ );
212
+ await page.waitForTimeout(kind === "hold" ? 900 : 550);
213
+ }
214
+
215
+ // Sequence: portrait -> landscape (shows in square video) + gestures
216
+ await setFrame("mobilePortrait");
217
+ await page.waitForTimeout(1200);
218
+ await gestureAt("tap");
219
+ await page.waitForTimeout(700);
220
+
221
+ await setFrame("mobileLandscape");
222
+ await page.waitForTimeout(1400);
223
+ await gestureAt("hold");
224
+ await page.waitForTimeout(700);
225
+
226
+ await setFrame("tabletPortrait");
227
+ await page.waitForTimeout(1200);
228
+ await gestureAt("tap");
229
+ await page.waitForTimeout(600);
230
+
231
+ await setFrame("tabletLandscape");
232
+ await page.waitForTimeout(1400);
233
+ await gestureAt("click");
234
+ await page.waitForTimeout(800);
235
+
236
+ await setFrame("desktop");
237
+ await page.waitForTimeout(1500);
238
+
239
+ await banner("waygraph · orientation + gestures · done");
240
+ await page.waitForTimeout(1000);
241
+
242
+ const vid = page.video();
243
+ await context.close();
244
+ await browser.close();
245
+
246
+ let saved = null;
247
+ if (vid) {
248
+ const raw = await vid.path();
249
+ saved = join(outDir, "orientation-gestures.webm");
250
+ try {
251
+ copyFileSync(raw, saved);
252
+ } catch {
253
+ saved = raw;
254
+ }
255
+ }
256
+ console.log("ORIENTATION_GESTURES_VIDEO", saved || outDir);
257
+ console.log(
258
+ "files",
259
+ readdirSync(outDir)
260
+ .filter((f) => f.endsWith(".webm"))
261
+ .join(", "),
262
+ );
@@ -17,7 +17,9 @@ export const NavCartBlock = defineNavClickBlock<CartPage>({
17
17
  click: ".shopping_cart_link",
18
18
  verify: [Trait.url({ pathname: "/cart.html" })],
19
19
  stubBefore: (ctx: StubCtx) => {
20
- ctx.title("Open cart");
20
+ ctx.title("Open cart · landscape");
21
+ // Device showcase: rotate tablet to landscape before the cart tap.
22
+ ctx.landscape();
21
23
  ctx.todoId("saucedemo-cart");
22
24
  ctx.todos([
23
25
  { id: "cart-find", text: "Find the product" },
@@ -38,11 +40,14 @@ export const NavCartBlock = defineNavClickBlock<CartPage>({
38
40
  zoomOut: false,
39
41
  followMouse: false,
40
42
  weight: "bold",
43
+ gesture: "tap",
41
44
  },
42
45
  });
43
46
  },
44
47
  stubAfter: (ctx: StubCtx) => {
45
- ctx.banner("Your cart");
48
+ ctx.banner("Your cart · desktop");
49
+ // Showcase finale: back to desktop.
50
+ ctx.clearDevice();
46
51
  ctx.zoomOut(false);
47
52
  ctx.ring("items", {
48
53
  selector: InventorySel.cartItem,
@@ -36,9 +36,10 @@ export const AddToCartBlock = defineEffectBlock<Checkpoint<string>, ItemInCart>(
36
36
  ],
37
37
  // Demo: zoom product -> add (stay zoomed), then badge after act.
38
38
  stubBefore: (ctx: StubCtx) => {
39
- ctx.title("Pick a product");
40
- // Back to desktop after mobile login (explicit clear - not omit/remain).
41
- ctx.clearDevice();
39
+ ctx.title("Pick a product · tablet");
40
+ // Device showcase: tablet portrait (toast + lerp from mobile landscape).
41
+ ctx.device("tablet");
42
+ ctx.portrait();
42
43
  ctx.todoId("saucedemo-cart");
43
44
  ctx.todos([
44
45
  { id: "cart-find", text: "Find the product" },
@@ -50,9 +50,10 @@ export const SubmitLoginActionBlock = defineMethodBlock<LoginPage, LoginSubmitOu
50
50
  // Open lifecycle: banner title, todos, Screen Studio camera zoom,
51
51
  // sequential highlight queue (appear / dwell / fade), focus + color.
52
52
  stubBefore: (ctx: StubCtx) => {
53
- ctx.title("Signing in");
54
- // 0.13+: mobile viewport + touch theater; omit on later blocks = remain.
53
+ ctx.title("Signing in · mobile");
54
+ // 0.13.3: mobile portrait + touch; tap Login.
55
55
  ctx.device("mobile");
56
+ ctx.portrait();
56
57
  ctx.todoId("saucedemo-login");
57
58
  ctx.todos([
58
59
  { id: "login-user", text: "Enter username" },
@@ -109,7 +110,9 @@ export const SubmitLoginActionBlock = defineMethodBlock<LoginPage, LoginSubmitOu
109
110
  });
110
111
  return;
111
112
  }
112
- ctx.banner("Inventory");
113
+ ctx.banner("Inventory · landscape");
114
+ // Rotate mobile to landscape on the product shelf (toast + lerp).
115
+ ctx.landscape();
113
116
  ctx.todoId("saucedemo-login");
114
117
  ctx.todos([
115
118
  { id: "login-user", text: "Enter username" },
@@ -0,0 +1,28 @@
1
+ import { Engine, start, end, withTitle } from "waygraph";
2
+ import { NavLoginBlock } from "../blocks/saucedemo-web/nav-login.block.js";
3
+ import { SubmitLoginForFlow } from "../blocks/saucedemo-web/methods/submit-login-for-flow.js";
4
+ import { AddToCartBlock } from "../blocks/saucedemo-web/inventory/methods/add-to-cart.effect.block.js";
5
+ import { NavCartBlock } from "../blocks/saucedemo-web/cart/nav-cart.block.js";
6
+
7
+ const engine = new Engine();
8
+
9
+ /**
10
+ * Short device/orientation showcase (0.13.3+):
11
+ * login mobile portrait -> land on inventory landscape ->
12
+ * add-to-cart tablet portrait -> open cart tablet landscape -> desktop
13
+ *
14
+ * Run:
15
+ * npm run demo:device
16
+ * npm run demo:device:video
17
+ */
18
+ export const deviceShowcaseFlow = withTitle(
19
+ engine.defineFlow([
20
+ start,
21
+ NavLoginBlock,
22
+ SubmitLoginForFlow,
23
+ AddToCartBlock,
24
+ NavCartBlock,
25
+ end,
26
+ ]),
27
+ "Device showcase",
28
+ );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "waygraph",
3
- "version": "0.13.2",
3
+ "version": "0.13.4",
4
4
  "type": "module",
5
5
  "description": "A typed graph of reusable Blocks for driving a browser through E2E flows.",
6
6
  "license": "MIT",
@@ -1,4 +1,5 @@
1
1
  import { defineNavClickBlock, Trait } from "waygraph";
2
+ import type { StubCtx } from "waygraph";
2
3
  import type { CartPage } from "../../../states/checkout.states.js";
3
4
  import { InventorySel } from "../inventory/methods/inventory-items.js";
4
5
 
@@ -15,8 +16,10 @@ export const NavCartBlock = defineNavClickBlock<CartPage>({
15
16
  checkpoint: "CartPage",
16
17
  click: ".shopping_cart_link",
17
18
  verify: [Trait.url({ pathname: "/cart.html" })],
18
- stubBefore: (ctx) => {
19
- ctx.title("Open cart");
19
+ stubBefore: (ctx: StubCtx) => {
20
+ ctx.title("Open cart · landscape");
21
+ // Device showcase: rotate tablet to landscape before the cart tap.
22
+ ctx.landscape();
20
23
  ctx.todoId("saucedemo-cart");
21
24
  ctx.todos([
22
25
  { id: "cart-find", text: "Find the product" },
@@ -37,11 +40,14 @@ export const NavCartBlock = defineNavClickBlock<CartPage>({
37
40
  zoomOut: false,
38
41
  followMouse: false,
39
42
  weight: "bold",
43
+ gesture: "tap",
40
44
  },
41
45
  });
42
46
  },
43
- stubAfter: (ctx) => {
44
- ctx.banner("Your cart");
47
+ stubAfter: (ctx: StubCtx) => {
48
+ ctx.banner("Your cart · desktop");
49
+ // Showcase finale: back to desktop.
50
+ ctx.clearDevice();
45
51
  ctx.zoomOut(false);
46
52
  ctx.ring("items", {
47
53
  selector: InventorySel.cartItem,
@@ -1,4 +1,5 @@
1
1
  import { defineEffectBlock, checkpoint, type Checkpoint, type WaygraphInstanceOption } from "waygraph";
2
+ import type { StubCtx } from "waygraph";
2
3
  import type { ItemInCart } from "../../../../states/checkout.states.js";
3
4
  import { SelectedItem } from "../../../../states/checkout.mem-keys.js";
4
5
  import { collectAddableItems, InventorySel } from "./inventory-items.js";
@@ -34,10 +35,11 @@ export const AddToCartBlock = defineEffectBlock<Checkpoint<string>, ItemInCart>(
34
35
  },
35
36
  ],
36
37
  // Demo: zoom product -> add (stay zoomed), then badge after act.
37
- stubBefore: (ctx) => {
38
- ctx.title("Pick a product");
39
- // Back to desktop after mobile login (explicit clear - not omit/remain).
40
- ctx.clearDevice();
38
+ stubBefore: (ctx: StubCtx) => {
39
+ ctx.title("Pick a product · tablet");
40
+ // Device showcase: tablet portrait (toast + lerp from mobile landscape).
41
+ ctx.device("tablet");
42
+ ctx.portrait();
41
43
  ctx.todoId("saucedemo-cart");
42
44
  ctx.todos([
43
45
  { id: "cart-find", text: "Find the product" },
@@ -68,7 +70,7 @@ export const AddToCartBlock = defineEffectBlock<Checkpoint<string>, ItemInCart>(
68
70
  },
69
71
  });
70
72
  },
71
- stubAfter: (ctx) => {
73
+ stubAfter: (ctx: StubCtx) => {
72
74
  ctx.banner("In the cart");
73
75
  ctx.todoId("saucedemo-cart");
74
76
  ctx.todos([
@@ -50,9 +50,10 @@ export const SubmitLoginActionBlock = defineMethodBlock<LoginPage, LoginSubmitOu
50
50
  // Open lifecycle: banner title, todos, Screen Studio camera zoom,
51
51
  // sequential highlight queue (appear / dwell / fade), focus + color.
52
52
  stubBefore: (ctx: StubCtx) => {
53
- ctx.title("Signing in");
54
- // 0.13+: mobile viewport + touch theater; omit on later blocks = remain.
53
+ ctx.title("Signing in · mobile");
54
+ // 0.13.3: mobile portrait + touch; tap Login.
55
55
  ctx.device("mobile");
56
+ ctx.portrait();
56
57
  ctx.todoId("saucedemo-login");
57
58
  ctx.todos([
58
59
  { id: "login-user", text: "Enter username" },
@@ -109,7 +110,9 @@ export const SubmitLoginActionBlock = defineMethodBlock<LoginPage, LoginSubmitOu
109
110
  });
110
111
  return;
111
112
  }
112
- ctx.banner("Inventory");
113
+ ctx.banner("Inventory · landscape");
114
+ // Rotate mobile to landscape on the product shelf (toast + lerp).
115
+ ctx.landscape();
113
116
  ctx.todoId("saucedemo-login");
114
117
  ctx.todos([
115
118
  { id: "login-user", text: "Enter username" },
@@ -0,0 +1,28 @@
1
+ import { Engine, start, end, withTitle } from "waygraph";
2
+ import { NavLoginBlock } from "../blocks/saucedemo-web/nav-login.block.js";
3
+ import { SubmitLoginForFlow } from "../blocks/saucedemo-web/methods/submit-login-for-flow.js";
4
+ import { AddToCartBlock } from "../blocks/saucedemo-web/inventory/methods/add-to-cart.effect.block.js";
5
+ import { NavCartBlock } from "../blocks/saucedemo-web/cart/nav-cart.block.js";
6
+
7
+ const engine = new Engine();
8
+
9
+ /**
10
+ * Short device/orientation showcase (0.13.3+):
11
+ * login mobile portrait -> land on inventory landscape ->
12
+ * add-to-cart tablet portrait -> open cart tablet landscape -> desktop
13
+ *
14
+ * Run:
15
+ * npm run demo:device
16
+ * npm run demo:device:video
17
+ */
18
+ export const deviceShowcaseFlow = withTitle(
19
+ engine.defineFlow([
20
+ start,
21
+ NavLoginBlock,
22
+ SubmitLoginForFlow,
23
+ AddToCartBlock,
24
+ NavCartBlock,
25
+ end,
26
+ ]),
27
+ "Device showcase",
28
+ );