waygraph 0.12.47 → 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
  )
@@ -1725,6 +1797,7 @@ async function cycleHighlightRings(page, highlights, gatesFast, opts) {
1725
1797
  const defaultHoldMs = opts && opts.defaultHoldMs != null ? opts.defaultHoldMs : null;
1726
1798
  const pace = opts && opts.pace !== undefined ? opts.pace : undefined;
1727
1799
  let dock = opts && opts.todoDock;
1800
+ const advanceTodos = !(opts && opts.advanceTodos === false);
1728
1801
  // Push stubAfter/before dock before the first ring so Method after-panels
1729
1802
  // show the updated checklist immediately (not only after the ring cycle).
1730
1803
  if (dock) {
@@ -1751,7 +1824,10 @@ async function cycleHighlightRings(page, highlights, gatesFast, opts) {
1751
1824
  size: h.size,
1752
1825
  weight: h.weight,
1753
1826
  });
1754
- if (dock) {
1827
+ // Do NOT advanceTodoDock by ring index after stubAfter already set
1828
+ // progress - that reset current back to 0 and the next block looked
1829
+ // blank. Mid-act Method advance still uses syncTodoDockAdvance.
1830
+ if (dock && advanceTodos) {
1755
1831
  dock = advanceTodoDock(dock, i);
1756
1832
  if (opts && opts.todoDockRef) opts.todoDockRef.current = dock;
1757
1833
  await page
@@ -1759,7 +1835,7 @@ async function cycleHighlightRings(page, highlights, gatesFast, opts) {
1759
1835
  if (window.__wgSyncTodos) window.__wgSyncTodos({ sync: "set", dock: d });
1760
1836
  }, dock)
1761
1837
  .catch(() => {});
1762
- } else if ((opts && opts.todos && opts.todos.length) || (h.todos && h.todos.length)) {
1838
+ } else if (!dock && ((opts && opts.todos && opts.todos.length) || (h.todos && h.todos.length))) {
1763
1839
  const rows =
1764
1840
  opts && opts.todos && opts.todos.length
1765
1841
  ? opts.todos
@@ -1863,7 +1939,14 @@ async function renderAfterStep(page, info) {
1863
1939
  todoDock: info.todoDock,
1864
1940
  todos: info.todos,
1865
1941
  todoSync: info.todoSync,
1942
+ todoDockRef: info.todoDockRef,
1943
+ // stubAfter already authored progress - do not re-index by ring 0..n
1944
+ advanceTodos: false,
1866
1945
  });
1946
+ // After-ring advances may have moved the dock - surface latest for caller.
1947
+ if (info.todoDockRef && info.todoDockRef.current) {
1948
+ info.todoDock = info.todoDockRef.current;
1949
+ }
1867
1950
  const afterPayload = { ...info, stepLabel: miniStepLabel(info) };
1868
1951
  await page
1869
1952
  .evaluate((info) => {
@@ -2670,6 +2753,49 @@ async function clickPulseAt(page, x, y, tone) {
2670
2753
  .catch(() => {});
2671
2754
  }
2672
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
+
2673
2799
  /**
2674
2800
  * True if the engine's own narrate() (waygraph's public export, for a Block
2675
2801
  * author explicitly captioning one action) JUST positioned the ring for
@@ -2686,7 +2812,7 @@ async function wasJustNarrated(page) {
2686
2812
  .catch(() => false);
2687
2813
  }
2688
2814
 
2689
- function instrumentInteractionHighlighting(page, mem, slowMo, pacing, stubBeforeRef, todoDockRef) {
2815
+ function instrumentInteractionHighlighting(page, mem, slowMo, pacing, stubBeforeRef, todoDockRef, deviceRef) {
2690
2816
  // Playwright's own slowMo ALREADY pauses after every single low-level
2691
2817
  // action it dispatches - and pressSequentially() fires one such action
2692
2818
  // PER CHARACTER. Also giving pressSequentially its own fixed delay
@@ -2850,7 +2976,36 @@ function instrumentInteractionHighlighting(page, mem, slowMo, pacing, stubBefore
2850
2976
  // mock click visually completes before the real one fires.
2851
2977
  await new Promise((res) => setTimeout(res, skipTheater() ? 0 : 500));
2852
2978
  }
2853
- 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
+ }
2854
3009
  await new Promise((res) => setTimeout(res, clickPostPop()));
2855
3010
  await hideRing(page);
2856
3011
  return result;
@@ -2960,7 +3115,9 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2960
3115
  const stubBeforeRef = { current: [] };
2961
3116
  /** Live todo dock for Method fill/click advance (same carry as lastTodoDock). */
2962
3117
  const todoDockRef = { current: undefined };
2963
- 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);
2964
3121
  // Force the panel checkbox from this process's flags/env at run start.
2965
3122
  // installOverlay only seeds localStorage when the key is null (so mid-run
2966
3123
  // checkbox clicks survive navigations). Without this, a prior --autoplay
@@ -2991,7 +3148,8 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2991
3148
  // setViewportSize entirely for the duration of step mode. Blocks that
2992
3149
  // call it don't need it to actually do anything here - their own
2993
3150
  // selectors/layouts still work fine at whatever size the real window
2994
- // already is.
3151
+ // already is. Authored ctx.device() still uses __wgRealSetViewportSize.
3152
+ page.__wgRealSetViewportSize = page.setViewportSize.bind(page);
2995
3153
  page.setViewportSize = async () => {};
2996
3154
  let resolveNext = null;
2997
3155
  // Render-to-gate race: a slide/step panel's Next button is wired to
@@ -3089,6 +3247,8 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
3089
3247
  let result;
3090
3248
  /** Carry floating todo dock across steps (empty stubBefore must not wipe). */
3091
3249
  let lastTodoDock = undefined;
3250
+ /** Carry device / touch fixture across steps (omit = keep, like todos). */
3251
+ let lastDevice = undefined;
3092
3252
  for (let i = 0; i < resolved.length; i++) {
3093
3253
  const r = resolved[i];
3094
3254
  // The block breadcrumb is scoped to THIS step's own episode, not the
@@ -3182,6 +3342,15 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
3182
3342
  });
3183
3343
  lastTodoDock = appliedBefore.dock;
3184
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
+ }
3185
3354
  const stubBeforeTodos =
3186
3355
  (lastTodoDock && lastTodoDock.groups[0] && lastTodoDock.groups[0].items) ||
3187
3356
  stubBeforePhase.todos ||
@@ -3339,6 +3508,10 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
3339
3508
  })
3340
3509
  .catch(() => {});
3341
3510
  result = stepOutcome.result;
3511
+ // Mid-act Method fill/click advances live on todoDockRef - fold that
3512
+ // back into lastTodoDock before stubAfter / next-block keep, or the
3513
+ // next step reverts to the pre-act (blank / index-0) checklist.
3514
+ if (todoDockRef.current) lastTodoDock = todoDockRef.current;
3342
3515
  // withExpectedFailure last block that SUCCEEDS on the intentional fail
3343
3516
  // branch (e.g. submit-login -> LoginPage + error banner). Still show
3344
3517
  // stubOnError rings + amber "expected outcome" panel - branching no
@@ -3403,6 +3576,7 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
3403
3576
  let afterOverlayTitle = overlayTitle;
3404
3577
  let afterTodoPos = overlayTodoPos;
3405
3578
  let appliedAfter = { dock: lastTodoDock, sync: "keep" };
3579
+ let appliedDeviceAfter = { device: lastDevice, sync: "keep" };
3406
3580
  if (hasAuthoredStubAfter(r.block, result, fixturesAfter)) {
3407
3581
  const afterPhase = await runStubPhase(r.block, "stubAfter", {
3408
3582
  out: result,
@@ -3416,6 +3590,15 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
3416
3590
  });
3417
3591
  lastTodoDock = appliedAfter.dock;
3418
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
+ }
3419
3602
  stubAfterTodos =
3420
3603
  (lastTodoDock && lastTodoDock.groups[0] && lastTodoDock.groups[0].items) ||
3421
3604
  afterPhase.todos ||
@@ -3467,6 +3650,7 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
3467
3650
  todoDock: lastTodoDock,
3468
3651
  todoSync: appliedAfter.sync,
3469
3652
  todoId: lastTodoDock && lastTodoDock.id,
3653
+ todoDockRef,
3470
3654
  forceCollapsed:
3471
3655
  !!process.env.WAYGRAPH_VIDEO ||
3472
3656
  process.env.WAYGRAPH_MINI === "1" ||
@@ -3474,6 +3658,8 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
3474
3658
  ? true
3475
3659
  : undefined,
3476
3660
  });
3661
+ // Ring cycle / stubAfter may have advanced the dock - persist for next block.
3662
+ if (todoDockRef.current) lastTodoDock = todoDockRef.current;
3477
3663
  await gate();
3478
3664
  }
3479
3665
  await teardownOverlay(page);
@@ -3862,10 +4048,11 @@ async function main() {
3862
4048
  // Lock DPR so --video-viewport 800x450 stays 800x450 in the .webm
3863
4049
  // (HiDPI hosts otherwise scale recordVideo to e.g. 1920x1080).
3864
4050
  deviceScaleFactor: 1,
4051
+ hasTouch: true,
3865
4052
  }
3866
4053
  : step
3867
- ? { baseURL, viewport: null }
3868
- : { baseURL, viewport: { width: 1280, height: 720 } };
4054
+ ? { baseURL, viewport: null, hasTouch: true }
4055
+ : { baseURL, viewport: { width: 1280, height: 720 }, hasTouch: true };
3869
4056
  if (videoDir) {
3870
4057
  contextOpts.recordVideo = fixedVideoViewport
3871
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.47",
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
  },