waygraph 0.12.48 → 0.13.0

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.
package/dist/cli.js CHANGED
@@ -230,6 +230,8 @@ import {
230
230
  applyTodoPhase,
231
231
  advanceTodoDock,
232
232
  completeSequentialTodoDock,
233
+ applyDevicePhase,
234
+ resolveDeviceState,
233
235
  } from "waygraph";
234
236
 
235
237
  function walkDir(dir, pattern) {
@@ -530,6 +532,18 @@ const RING_CSS =
530
532
  "#wg-todo-dock[data-pos=right]{transform:translateX(calc(100vw - 100% - 28px));}" +
531
533
  "#wg-todo-dock[data-pos=left]{transform:translateX(0);}" +
532
534
  "#wg-todo-dock #wg-todos{list-style:none;margin:0;padding:0;background:transparent;border:none;}" +
535
+ // Device / touch badge (0.13+) - shows preset + remain|desktop reset.
536
+ "#wg-device-badge{position:fixed;z-index:2147483646;top:14px;right:14px;" +
537
+ "padding:6px 10px;border-radius:999px;background:rgba(20,10,40,.92);color:#f0e8ff;" +
538
+ "border:1px solid rgba(124,58,237,.5);font:700 11px/1.2 system-ui,sans-serif;" +
539
+ "letter-spacing:.04em;text-transform:uppercase;pointer-events:none;" +
540
+ "box-shadow:0 4px 14px rgba(0,0,0,.3);opacity:0;transition:opacity .25s ease;}" +
541
+ "#wg-device-badge.wg-in{opacity:1;}" +
542
+ "#wg-device-badge[data-preset=mobile]{border-color:#3B82F6;}" +
543
+ "#wg-device-badge[data-preset=tablet]{border-color:#22C55E;}" +
544
+ "#wg-device-badge[data-preset=desktop]{border-color:#9CA3AF;}" +
545
+ "#wg-device-badge[data-touch=1]::after{content:' · touch';opacity:.85;}" +
546
+ "#wg-cursor[data-touch=1]{width:28px;height:28px;}" +
533
547
  "#wg-todos{list-style:none;margin:0 0 12px;padding:8px 10px;background:#0f0620;border-radius:8px;" +
534
548
  "border:1px solid #3a2a60;}" +
535
549
  "#wg-todos li{display:flex;gap:8px;align-items:flex-start;margin:0 0 6px;font:600 12.5px/1.35 system-ui,sans-serif;}" +
@@ -1252,6 +1266,64 @@ async function installOverlay(page, title) {
1252
1266
  window.__wgStampModal(dock, "todos", { ready: true });
1253
1267
  }
1254
1268
  };
1269
+ // Device / touch badge (0.13+) - mirror todo sync keep/set/clear.
1270
+ // Payload: { sync, device?, remain? }
1271
+ window.__wgSyncDevice = (payload) => {
1272
+ const sync =
1273
+ payload && (payload.sync === "clear" || payload.sync === "keep" || payload.sync === "set")
1274
+ ? payload.sync
1275
+ : "keep";
1276
+ const device = payload && payload.device ? payload.device : null;
1277
+ let badge = document.getElementById("wg-device-badge");
1278
+ const cursor = document.getElementById("wg-cursor");
1279
+ const MOUSE_SVG =
1280
+ "<svg viewBox='0 0 32 32' width='24' height='24'>" +
1281
+ "<path fill='#0C0C1A' stroke='#fff' stroke-width='1.4' stroke-linejoin='round' " +
1282
+ "d='M6 3.5l1.4 22.5 5.8-5.4 4.2 9.4 3.6-1.6-4.2-9.2H26z'/></svg>";
1283
+ const FINGER_SVG =
1284
+ "<svg viewBox='0 0 32 32' width='28' height='28'>" +
1285
+ "<ellipse cx='16' cy='22' rx='7' ry='8' fill='#0C0C1A' stroke='#fff' stroke-width='1.4'/>" +
1286
+ "<rect x='12' y='6' width='8' height='16' rx='4' fill='#0C0C1A' stroke='#fff' stroke-width='1.4'/>" +
1287
+ "</svg>";
1288
+ if (sync === "keep" && !device) return;
1289
+ if (sync === "clear" || (device && device.preset === "desktop" && !device.touchMode)) {
1290
+ if (badge) badge.remove();
1291
+ if (cursor) {
1292
+ cursor.removeAttribute("data-touch");
1293
+ cursor.innerHTML = MOUSE_SVG;
1294
+ }
1295
+ if (sync === "clear" && !device) return;
1296
+ // clear with desktop device: still hide badge (back to main)
1297
+ if (device && device.preset === "desktop" && !device.touchMode) return;
1298
+ }
1299
+ if (!device) return;
1300
+ const preset = device.preset || "desktop";
1301
+ const touch = !!device.touchMode;
1302
+ const remain = payload.remain !== false && sync !== "clear";
1303
+ if (!badge) {
1304
+ badge = document.createElement("div");
1305
+ badge.id = "wg-device-badge";
1306
+ badge.setAttribute("data-wg-ui", "1");
1307
+ document.documentElement.appendChild(badge);
1308
+ }
1309
+ badge.dataset.preset = preset;
1310
+ badge.dataset.touch = touch ? "1" : "0";
1311
+ const label =
1312
+ preset === "desktop" && !touch
1313
+ ? "desktop"
1314
+ : preset + (remain ? " · remain" : " · set");
1315
+ badge.textContent = label;
1316
+ badge.classList.add("wg-in");
1317
+ if (cursor) {
1318
+ if (touch) {
1319
+ cursor.setAttribute("data-touch", "1");
1320
+ cursor.innerHTML = FINGER_SVG;
1321
+ } else {
1322
+ cursor.removeAttribute("data-touch");
1323
+ cursor.innerHTML = MOUSE_SVG;
1324
+ }
1325
+ }
1326
+ };
1255
1327
  },
1256
1328
  { title, favicon: WAYGRAPH_FAVICON, bannerPos, todoPos, envAutoplay },
1257
1329
  )
@@ -2681,6 +2753,49 @@ async function clickPulseAt(page, x, y, tone) {
2681
2753
  .catch(() => {});
2682
2754
  }
2683
2755
 
2756
+ /**
2757
+ * Apply authored device fixture to the live page (0.13+).
2758
+ * Uses the real setViewportSize (step mode noops block-authored calls).
2759
+ * sync keep with no device = no-op; clear / desktop = restore main feel.
2760
+ */
2761
+ async function applyDeviceToPage(page, device, sync) {
2762
+ const mode = sync || (device ? "set" : "keep");
2763
+ if (mode === "keep" && !device) return;
2764
+ const d =
2765
+ device ||
2766
+ resolveDeviceState("desktop", false) || {
2767
+ preset: "desktop",
2768
+ viewport: { width: 1280, height: 720, deviceScaleFactor: 1 },
2769
+ isMobile: false,
2770
+ hasTouch: false,
2771
+ touchMode: false,
2772
+ };
2773
+ const realSet =
2774
+ typeof page.__wgRealSetViewportSize === "function"
2775
+ ? page.__wgRealSetViewportSize
2776
+ : page.setViewportSize.bind(page);
2777
+ try {
2778
+ await realSet({
2779
+ width: d.viewport.width,
2780
+ height: d.viewport.height,
2781
+ });
2782
+ } catch {
2783
+ /* maximized / null viewport hosts may reject - badge still updates */
2784
+ }
2785
+ await page
2786
+ .evaluate(
2787
+ (payload) => {
2788
+ if (window.__wgSyncDevice) window.__wgSyncDevice(payload);
2789
+ },
2790
+ {
2791
+ sync: mode,
2792
+ device: d,
2793
+ remain: mode === "keep" || mode === "set",
2794
+ },
2795
+ )
2796
+ .catch(() => {});
2797
+ }
2798
+
2684
2799
  /**
2685
2800
  * True if the engine's own narrate() (waygraph's public export, for a Block
2686
2801
  * author explicitly captioning one action) JUST positioned the ring for
@@ -2697,7 +2812,7 @@ async function wasJustNarrated(page) {
2697
2812
  .catch(() => false);
2698
2813
  }
2699
2814
 
2700
- function instrumentInteractionHighlighting(page, mem, slowMo, pacing, stubBeforeRef, todoDockRef) {
2815
+ function instrumentInteractionHighlighting(page, mem, slowMo, pacing, stubBeforeRef, todoDockRef, deviceRef) {
2701
2816
  // Playwright's own slowMo ALREADY pauses after every single low-level
2702
2817
  // action it dispatches - and pressSequentially() fires one such action
2703
2818
  // PER CHARACTER. Also giving pressSequentially its own fixed delay
@@ -2861,7 +2976,36 @@ function instrumentInteractionHighlighting(page, mem, slowMo, pacing, stubBefore
2861
2976
  // mock click visually completes before the real one fires.
2862
2977
  await new Promise((res) => setTimeout(res, skipTheater() ? 0 : 500));
2863
2978
  }
2864
- const result = await originalClick.call(this, options);
2979
+ // Touch theater (0.13+): prefer Locator.tap / long-press when device
2980
+ // touchMode is on (context launched with hasTouch). gesture on stub
2981
+ // wins: tap | hold | click.
2982
+ let result;
2983
+ const stubsForGesture = (stubBeforeRef && stubBeforeRef.current) || [];
2984
+ let gesture = "click";
2985
+ try {
2986
+ const idx = await matchStubIndexForLocator(page, this, stubsForGesture);
2987
+ const stub = idx >= 0 ? stubsForGesture[idx] : null;
2988
+ if (stub && stub.gesture) gesture = stub.gesture;
2989
+ else if (deviceRef && deviceRef.current && deviceRef.current.touchMode) gesture = "tap";
2990
+ } catch {
2991
+ if (deviceRef && deviceRef.current && deviceRef.current.touchMode) gesture = "tap";
2992
+ }
2993
+ if (gesture === "tap" || gesture === "hold") {
2994
+ try {
2995
+ if (gesture === "hold") {
2996
+ result = await this.tap({
2997
+ ...(options || {}),
2998
+ delay: skipTheater() ? 0 : 600,
2999
+ });
3000
+ } else {
3001
+ result = await this.tap(options);
3002
+ }
3003
+ } catch {
3004
+ result = await originalClick.call(this, options);
3005
+ }
3006
+ } else {
3007
+ result = await originalClick.call(this, options);
3008
+ }
2865
3009
  await new Promise((res) => setTimeout(res, clickPostPop()));
2866
3010
  await hideRing(page);
2867
3011
  return result;
@@ -2971,7 +3115,9 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2971
3115
  const stubBeforeRef = { current: [] };
2972
3116
  /** Live todo dock for Method fill/click advance (same carry as lastTodoDock). */
2973
3117
  const todoDockRef = { current: undefined };
2974
- instrumentInteractionHighlighting(page, mem, slowMo, pacing, stubBeforeRef, todoDockRef);
3118
+ /** Live device fixture for touch theater (same carry as lastDevice). */
3119
+ const deviceRef = { current: undefined };
3120
+ instrumentInteractionHighlighting(page, mem, slowMo, pacing, stubBeforeRef, todoDockRef, deviceRef);
2975
3121
  // Force the panel checkbox from this process's flags/env at run start.
2976
3122
  // installOverlay only seeds localStorage when the key is null (so mid-run
2977
3123
  // checkbox clicks survive navigations). Without this, a prior --autoplay
@@ -3002,7 +3148,8 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
3002
3148
  // setViewportSize entirely for the duration of step mode. Blocks that
3003
3149
  // call it don't need it to actually do anything here - their own
3004
3150
  // selectors/layouts still work fine at whatever size the real window
3005
- // already is.
3151
+ // already is. Authored ctx.device() still uses __wgRealSetViewportSize.
3152
+ page.__wgRealSetViewportSize = page.setViewportSize.bind(page);
3006
3153
  page.setViewportSize = async () => {};
3007
3154
  let resolveNext = null;
3008
3155
  // Render-to-gate race: a slide/step panel's Next button is wired to
@@ -3100,6 +3247,8 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
3100
3247
  let result;
3101
3248
  /** Carry floating todo dock across steps (empty stubBefore must not wipe). */
3102
3249
  let lastTodoDock = undefined;
3250
+ /** Carry device / touch fixture across steps (omit = keep, like todos). */
3251
+ let lastDevice = undefined;
3103
3252
  for (let i = 0; i < resolved.length; i++) {
3104
3253
  const r = resolved[i];
3105
3254
  // The block breadcrumb is scoped to THIS step's own episode, not the
@@ -3193,6 +3342,15 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
3193
3342
  });
3194
3343
  lastTodoDock = appliedBefore.dock;
3195
3344
  todoDockRef.current = lastTodoDock;
3345
+ const appliedDeviceBefore = applyDevicePhase(lastDevice, {
3346
+ deviceSync: stubBeforePhase.deviceSync,
3347
+ device: stubBeforePhase.device,
3348
+ });
3349
+ lastDevice = appliedDeviceBefore.device;
3350
+ deviceRef.current = lastDevice;
3351
+ if (appliedDeviceBefore.sync !== "keep" || lastDevice) {
3352
+ await applyDeviceToPage(page, lastDevice, appliedDeviceBefore.sync);
3353
+ }
3196
3354
  const stubBeforeTodos =
3197
3355
  (lastTodoDock && lastTodoDock.groups[0] && lastTodoDock.groups[0].items) ||
3198
3356
  stubBeforePhase.todos ||
@@ -3418,6 +3576,7 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
3418
3576
  let afterOverlayTitle = overlayTitle;
3419
3577
  let afterTodoPos = overlayTodoPos;
3420
3578
  let appliedAfter = { dock: lastTodoDock, sync: "keep" };
3579
+ let appliedDeviceAfter = { device: lastDevice, sync: "keep" };
3421
3580
  if (hasAuthoredStubAfter(r.block, result, fixturesAfter)) {
3422
3581
  const afterPhase = await runStubPhase(r.block, "stubAfter", {
3423
3582
  out: result,
@@ -3431,6 +3590,15 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
3431
3590
  });
3432
3591
  lastTodoDock = appliedAfter.dock;
3433
3592
  todoDockRef.current = lastTodoDock;
3593
+ appliedDeviceAfter = applyDevicePhase(lastDevice, {
3594
+ deviceSync: afterPhase.deviceSync,
3595
+ device: afterPhase.device,
3596
+ });
3597
+ lastDevice = appliedDeviceAfter.device;
3598
+ deviceRef.current = lastDevice;
3599
+ if (appliedDeviceAfter.sync !== "keep" || (appliedDeviceAfter.sync === "set" && lastDevice)) {
3600
+ await applyDeviceToPage(page, lastDevice, appliedDeviceAfter.sync);
3601
+ }
3434
3602
  stubAfterTodos =
3435
3603
  (lastTodoDock && lastTodoDock.groups[0] && lastTodoDock.groups[0].items) ||
3436
3604
  afterPhase.todos ||
@@ -3880,10 +4048,11 @@ async function main() {
3880
4048
  // Lock DPR so --video-viewport 800x450 stays 800x450 in the .webm
3881
4049
  // (HiDPI hosts otherwise scale recordVideo to e.g. 1920x1080).
3882
4050
  deviceScaleFactor: 1,
4051
+ hasTouch: true,
3883
4052
  }
3884
4053
  : step
3885
- ? { baseURL, viewport: null }
3886
- : { baseURL, viewport: { width: 1280, height: 720 } };
4054
+ ? { baseURL, viewport: null, hasTouch: true }
4055
+ : { baseURL, viewport: { width: 1280, height: 720 }, hasTouch: true };
3887
4056
  if (videoDir) {
3888
4057
  contextOpts.recordVideo = fixedVideoViewport
3889
4058
  ? { dir: videoDir, size: fixedVideoViewport }
@@ -117,6 +117,57 @@ export type StubPhaseFixtures = {
117
117
  * Authored control (also click / WAYGRAPH_TODO_POS / --todo-left|right).
118
118
  */
119
119
  todoPos?: "left" | "right";
120
+ /**
121
+ * Viewport preset: `mobile` | `tablet` | `desktop` (default / clear).
122
+ * Omit on later blocks = **keep** (same persist pattern as todos).
123
+ */
124
+ device?: DevicePreset | DeviceState;
125
+ /** Explicit pixel viewport (wins over preset width/height when both set). */
126
+ viewport?: DeviceViewport;
127
+ /** Touch theater + Playwright touchscreen when true. */
128
+ touch?: boolean;
129
+ };
130
+ /** Named viewport presets (0.13+). */
131
+ export type DevicePreset = "mobile" | "tablet" | "desktop";
132
+ /** Explicit viewport box. */
133
+ export type DeviceViewport = {
134
+ width: number;
135
+ height: number;
136
+ deviceScaleFactor?: number;
137
+ };
138
+ /**
139
+ * Carried device fixture (todo-dock shaped persist).
140
+ * Omit on a later step = keep; hideDevice/clearDevice/desktop = clear to desktop.
141
+ */
142
+ export type DeviceState = {
143
+ preset: DevicePreset;
144
+ viewport: DeviceViewport;
145
+ /** Playwright-ish: treat as mobile layout. */
146
+ isMobile: boolean;
147
+ /** Context needs hasTouch for touchscreen.tap (demo launches with hasTouch). */
148
+ hasTouch: boolean;
149
+ /** Demo gesture theater: finger cursor, tap/hold timing, prefer touchscreen. */
150
+ touchMode: boolean;
151
+ };
152
+ /** Built-in preset sizes (CSS pixels). */
153
+ export declare const DEVICE_PRESETS: Record<DevicePreset, Omit<DeviceState, "touchMode"> & {
154
+ touchMode?: boolean;
155
+ }>;
156
+ export declare function normalizeDevicePreset(raw: unknown): DevicePreset | undefined;
157
+ /** Resolve a preset name or partial state into a full DeviceState. */
158
+ export declare function resolveDeviceState(input: DevicePreset | DeviceState | DeviceViewport | undefined, touchOverride?: boolean): DeviceState | undefined;
159
+ /**
160
+ * Carry-forward for device fixtures (mirror {@link applyTodoPhase}).
161
+ * - clear: hideDevice / clearDevice / device("desktop") with clear intent
162
+ * - set: author set device/viewport/touch this phase
163
+ * - keep: omit - preserve previous (whole episode remain)
164
+ */
165
+ export declare function applyDevicePhase(prev: DeviceState | undefined, phase: {
166
+ deviceSync?: "set" | "clear" | "keep";
167
+ device?: DeviceState;
168
+ }): {
169
+ device: DeviceState | undefined;
170
+ sync: "set" | "clear" | "keep";
120
171
  };
121
172
  /** One named highlight slot on a block (selector + caption). */
122
173
  export interface WaygraphHighlightStub extends FixtureDurationFields {
@@ -176,6 +227,13 @@ export interface WaygraphHighlightStub extends FixtureDurationFields {
176
227
  * for authored stub queues; set false to keep cursor hidden).
177
228
  */
178
229
  cursor?: boolean;
230
+ /**
231
+ * Pointer feel when touchMode is on (0.13+):
232
+ * - `tap` - short press via touchscreen (default in touch mode)
233
+ * - `hold` - long-press (~600ms) then release
234
+ * - `click` - mouse click even in touch mode
235
+ */
236
+ gesture?: "tap" | "hold" | "click";
179
237
  }
180
238
  /** Flow fixture patch: label required; selector optional (inherits from stub). */
181
239
  export type WaygraphHighlightFixture = Partial<Pick<WaygraphHighlightStub, "selector">> & Required<Pick<WaygraphHighlightStub, "label">> & Pick<WaygraphHighlightStub, "detail" | "tag" | "tone" | "size" | "weight" | "zoom" | "zoomOut" | "focus" | "followMouse" | "follow" | "color" | "cursor"> & FixtureDurationFields;
@@ -238,6 +296,23 @@ export type StubCtx<Out extends Checkpoint<string> = Checkpoint<string>> = {
238
296
  hideTodos(): void;
239
297
  /** Alias for {@link StubCtx.hideTodos}. */
240
298
  clearTodos(): void;
299
+ /**
300
+ * Viewport / device fixture (0.13+). `mobile` | `tablet` | `desktop`.
301
+ * Omit on later blocks = **keep** (same persist as todos). Use
302
+ * {@link StubCtx.hideDevice} / {@link StubCtx.clearDevice} to return to desktop.
303
+ */
304
+ device(preset: DevicePreset | DeviceState): void;
305
+ /** Explicit CSS-pixel viewport (implies mobile-ish unless desktop-sized). */
306
+ viewport(box: DeviceViewport): void;
307
+ /** Enable / disable touch theater + touchscreen gestures. */
308
+ touch(on?: boolean): void;
309
+ /**
310
+ * Reset to desktop viewport and clear touch mode. Omit device on a later
311
+ * block = **keep**; only hideDevice / clearDevice / device("desktop") clears.
312
+ */
313
+ hideDevice(): void;
314
+ /** Alias for {@link StubCtx.hideDevice}. */
315
+ clearDevice(): void;
241
316
  /** Default camera zoom for rings without their own zoom. */
242
317
  zoom(n: number): void;
243
318
  /**
@@ -280,6 +355,15 @@ export type StubPhaseResult = {
280
355
  * - `keep` - author omitted todos; demo must leave previous dock alone
281
356
  */
282
357
  todoSync?: "set" | "clear" | "keep";
358
+ /** Resolved device after this phase (undefined when keep with no prior). */
359
+ device?: DeviceState;
360
+ /**
361
+ * Device intent (mirror todoSync):
362
+ * - `set` - apply {@link device}
363
+ * - `clear` - reset to desktop (hideDevice / clearDevice / device desktop)
364
+ * - `keep` - author omitted device; demo must leave previous alone
365
+ */
366
+ deviceSync?: "set" | "clear" | "keep";
283
367
  zoom?: number;
284
368
  zoomOut?: boolean;
285
369
  title?: string;
@@ -12,6 +12,123 @@
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). */
16
+ export const DEVICE_PRESETS = {
17
+ mobile: {
18
+ preset: "mobile",
19
+ viewport: { width: 390, height: 844, deviceScaleFactor: 2 },
20
+ isMobile: true,
21
+ hasTouch: true,
22
+ touchMode: true,
23
+ },
24
+ tablet: {
25
+ preset: "tablet",
26
+ viewport: { width: 768, height: 1024, deviceScaleFactor: 2 },
27
+ isMobile: true,
28
+ hasTouch: true,
29
+ touchMode: true,
30
+ },
31
+ desktop: {
32
+ preset: "desktop",
33
+ viewport: { width: 1280, height: 720, deviceScaleFactor: 1 },
34
+ isMobile: false,
35
+ hasTouch: false,
36
+ touchMode: false,
37
+ },
38
+ };
39
+ export function normalizeDevicePreset(raw) {
40
+ const s = String(raw ?? "")
41
+ .trim()
42
+ .toLowerCase();
43
+ if (s === "mobile" || s === "phone" || s === "m")
44
+ return "mobile";
45
+ if (s === "tablet" || s === "pad" || s === "t")
46
+ return "tablet";
47
+ if (s === "desktop" || s === "desk" || s === "d" || s === "main")
48
+ return "desktop";
49
+ return undefined;
50
+ }
51
+ /** Resolve a preset name or partial state into a full DeviceState. */
52
+ export function resolveDeviceState(input, touchOverride) {
53
+ if (input === undefined)
54
+ return undefined;
55
+ if (typeof input === "string") {
56
+ const p = normalizeDevicePreset(input);
57
+ if (!p)
58
+ return undefined;
59
+ const base = DEVICE_PRESETS[p];
60
+ const touchMode = touchOverride !== undefined ? !!touchOverride : !!base.touchMode;
61
+ return {
62
+ preset: base.preset,
63
+ viewport: { ...base.viewport },
64
+ isMobile: base.isMobile,
65
+ hasTouch: base.hasTouch || touchMode,
66
+ touchMode,
67
+ };
68
+ }
69
+ if ("width" in input && "height" in input && !("preset" in input)) {
70
+ const vp = input;
71
+ const touchMode = touchOverride !== undefined ? !!touchOverride : true;
72
+ return {
73
+ 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
+ },
81
+ isMobile: true,
82
+ hasTouch: true,
83
+ touchMode,
84
+ };
85
+ }
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
+ };
112
+ }
113
+ /**
114
+ * Carry-forward for device fixtures (mirror {@link applyTodoPhase}).
115
+ * - clear: hideDevice / clearDevice / device("desktop") with clear intent
116
+ * - set: author set device/viewport/touch this phase
117
+ * - keep: omit - preserve previous (whole episode remain)
118
+ */
119
+ export function applyDevicePhase(prev, phase) {
120
+ const sync = phase.deviceSync ||
121
+ (phase.device ? "set" : "keep");
122
+ if (sync === "clear") {
123
+ return { device: resolveDeviceState("desktop", false), sync: "clear" };
124
+ }
125
+ if (sync === "set") {
126
+ if (phase.device)
127
+ return { device: phase.device, sync: "set" };
128
+ return { device: resolveDeviceState("desktop", false), sync: "clear" };
129
+ }
130
+ return { device: prev, sync: "keep" };
131
+ }
15
132
  /** Apply Flow defaults under per-slot authored values (slot wins when set). */
16
133
  export function applyHighlightStyleDefaults(slot, defaults) {
17
134
  const tone = slot.tone !== undefined && slot.tone !== ""
@@ -600,6 +717,57 @@ function createStubCtx(bag, opts) {
600
717
  bag.todos = [];
601
718
  delete bag.todoGroups;
602
719
  },
720
+ device(preset) {
721
+ bag.deviceTouched = true;
722
+ bag.hideDevice = false;
723
+ const resolved = resolveDeviceState(preset, bag.touchOverride);
724
+ if (resolved) {
725
+ bag.device = resolved;
726
+ if (resolved.preset === "desktop" && !resolved.touchMode) {
727
+ bag.hideDevice = true;
728
+ }
729
+ }
730
+ },
731
+ viewport(box) {
732
+ bag.deviceTouched = true;
733
+ bag.hideDevice = false;
734
+ const resolved = resolveDeviceState(box, bag.touchOverride);
735
+ if (resolved)
736
+ bag.device = resolved;
737
+ },
738
+ touch(on) {
739
+ bag.deviceTouched = true;
740
+ const enabled = on === undefined ? true : !!on;
741
+ bag.touchOverride = enabled;
742
+ if (enabled)
743
+ bag.hideDevice = false;
744
+ if (bag.device) {
745
+ bag.device = {
746
+ ...bag.device,
747
+ touchMode: enabled,
748
+ hasTouch: bag.device.hasTouch || enabled,
749
+ };
750
+ if (enabled)
751
+ bag.hideDevice = false;
752
+ }
753
+ else if (enabled) {
754
+ const base = resolveDeviceState("desktop", true);
755
+ if (base)
756
+ bag.device = { ...base, touchMode: true, hasTouch: true };
757
+ }
758
+ },
759
+ hideDevice() {
760
+ bag.deviceTouched = true;
761
+ bag.hideDevice = true;
762
+ bag.device = resolveDeviceState("desktop", false);
763
+ bag.touchOverride = false;
764
+ },
765
+ clearDevice() {
766
+ bag.deviceTouched = true;
767
+ bag.hideDevice = true;
768
+ bag.device = resolveDeviceState("desktop", false);
769
+ bag.touchOverride = false;
770
+ },
603
771
  zoom(n) {
604
772
  if (Number.isFinite(n) && n > 0)
605
773
  bag.zoom = n;
@@ -648,6 +816,27 @@ function createStubCtx(bag, opts) {
648
816
  bag.hideTodos = !partial.todoGroups || !partial.todoGroups.length;
649
817
  bag.todoGroups = partial.todoGroups;
650
818
  }
819
+ if (partial.touch !== undefined) {
820
+ bag.deviceTouched = true;
821
+ bag.touchOverride = !!partial.touch;
822
+ }
823
+ if (partial.device !== undefined) {
824
+ bag.deviceTouched = true;
825
+ bag.hideDevice = false;
826
+ const resolved = resolveDeviceState(partial.device, bag.touchOverride);
827
+ if (resolved) {
828
+ bag.device = resolved;
829
+ if (resolved.preset === "desktop" && !resolved.touchMode)
830
+ bag.hideDevice = true;
831
+ }
832
+ }
833
+ if (partial.viewport !== undefined) {
834
+ bag.deviceTouched = true;
835
+ bag.hideDevice = false;
836
+ const resolved = resolveDeviceState(partial.viewport, bag.touchOverride);
837
+ if (resolved)
838
+ bag.device = resolved;
839
+ }
651
840
  if (partial.zoom !== undefined && Number.isFinite(partial.zoom) && partial.zoom > 0) {
652
841
  bag.zoom = partial.zoom;
653
842
  }
@@ -812,12 +1001,37 @@ export async function runStubPhase(block, phase, opts) {
812
1001
  : bag.todoTouched && !todoDock
813
1002
  ? "clear"
814
1003
  : "keep";
1004
+ let deviceState;
1005
+ if (bag.hideDevice) {
1006
+ deviceState = resolveDeviceState("desktop", false);
1007
+ }
1008
+ else if (bag.device) {
1009
+ deviceState = bag.touchOverride !== undefined
1010
+ ? {
1011
+ ...bag.device,
1012
+ touchMode: bag.touchOverride,
1013
+ hasTouch: bag.device.hasTouch || bag.touchOverride,
1014
+ }
1015
+ : bag.device;
1016
+ }
1017
+ else if (bag.touchOverride !== undefined) {
1018
+ deviceState = resolveDeviceState("desktop", bag.touchOverride);
1019
+ }
1020
+ const deviceSync = bag.hideDevice
1021
+ ? "clear"
1022
+ : bag.deviceTouched && deviceState
1023
+ ? "set"
1024
+ : bag.deviceTouched && !deviceState
1025
+ ? "clear"
1026
+ : "keep";
815
1027
  return {
816
1028
  highlights,
817
1029
  todos,
818
1030
  todoSync,
1031
+ deviceSync,
819
1032
  ...(bag.todoIndex !== undefined ? { todoIndex: bag.todoIndex } : {}),
820
1033
  ...(todoDock ? { todoDock } : {}),
1034
+ ...(deviceState ? { device: deviceState } : {}),
821
1035
  ...(bag.zoom !== undefined ? { zoom: bag.zoom } : {}),
822
1036
  ...(bag.zoomOut !== undefined ? { zoomOut: bag.zoomOut } : {}),
823
1037
  ...(bag.title !== undefined && bag.title !== "" ? { title: bag.title } : {}),
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, 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, } 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, 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";
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, 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, 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";
@@ -37,6 +37,8 @@ export const AddToCartBlock = defineEffectBlock<Checkpoint<string>, ItemInCart>(
37
37
  // Demo: zoom product -> add (stay zoomed), then badge after act.
38
38
  stubBefore: (ctx: StubCtx) => {
39
39
  ctx.title("Pick a product");
40
+ // Back to desktop after mobile login (explicit clear - not omit/remain).
41
+ ctx.clearDevice();
40
42
  ctx.todoId("saucedemo-cart");
41
43
  ctx.todos([
42
44
  { id: "cart-find", text: "Find the product" },
@@ -51,11 +51,13 @@ export const SubmitLoginActionBlock = defineMethodBlock<LoginPage, LoginSubmitOu
51
51
  // sequential highlight queue (appear / dwell / fade), focus + color.
52
52
  stubBefore: (ctx: StubCtx) => {
53
53
  ctx.title("Signing in");
54
+ // 0.13+: mobile viewport + touch theater; omit on later blocks = remain.
55
+ ctx.device("mobile");
54
56
  ctx.todoId("saucedemo-login");
55
57
  ctx.todos([
56
58
  { id: "login-user", text: "Enter username" },
57
59
  { id: "login-pass", text: "Enter password" },
58
- { id: "login-submit", text: "Click Login" },
60
+ { id: "login-submit", text: "Tap Login" },
59
61
  ]);
60
62
  ctx.todoIndex(0);
61
63
  ctx.zoom(1.35);
@@ -81,6 +83,7 @@ export const SubmitLoginActionBlock = defineMethodBlock<LoginPage, LoginSubmitOu
81
83
  color: "#86efac",
82
84
  zoom: 1.55,
83
85
  weight: "bold",
86
+ gesture: "tap",
84
87
  },
85
88
  });
86
89
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "waygraph",
3
- "version": "0.12.48",
3
+ "version": "0.13.0",
4
4
  "type": "module",
5
5
  "description": "A typed graph of reusable Blocks for driving a browser through E2E flows.",
6
6
  "license": "MIT",
@@ -36,6 +36,8 @@ export const AddToCartBlock = defineEffectBlock<Checkpoint<string>, ItemInCart>(
36
36
  // Demo: zoom product -> add (stay zoomed), then badge after act.
37
37
  stubBefore: (ctx) => {
38
38
  ctx.title("Pick a product");
39
+ // Back to desktop after mobile login (explicit clear - not omit/remain).
40
+ ctx.clearDevice();
39
41
  ctx.todoId("saucedemo-cart");
40
42
  ctx.todos([
41
43
  { id: "cart-find", text: "Find the product" },
@@ -51,11 +51,13 @@ export const SubmitLoginActionBlock = defineMethodBlock<LoginPage, LoginSubmitOu
51
51
  // sequential highlight queue (appear / dwell / fade), focus + color.
52
52
  stubBefore: (ctx: StubCtx) => {
53
53
  ctx.title("Signing in");
54
+ // 0.13+: mobile viewport + touch theater; omit on later blocks = remain.
55
+ ctx.device("mobile");
54
56
  ctx.todoId("saucedemo-login");
55
57
  ctx.todos([
56
58
  { id: "login-user", text: "Enter username" },
57
59
  { id: "login-pass", text: "Enter password" },
58
- { id: "login-submit", text: "Click Login" },
60
+ { id: "login-submit", text: "Tap Login" },
59
61
  ]);
60
62
  ctx.todoIndex(0);
61
63
  ctx.zoom(1.35);
@@ -81,6 +83,7 @@ export const SubmitLoginActionBlock = defineMethodBlock<LoginPage, LoginSubmitOu
81
83
  color: "#86efac",
82
84
  zoom: 1.55,
83
85
  weight: "bold",
86
+ gesture: "tap",
84
87
  },
85
88
  });
86
89
  },