waygraph 0.13.1 → 0.13.3

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
@@ -532,17 +532,43 @@ const RING_CSS =
532
532
  "#wg-todo-dock[data-pos=right]{transform:translateX(calc(100vw - 100% - 28px));}" +
533
533
  "#wg-todo-dock[data-pos=left]{transform:translateX(0);}" +
534
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.
535
+ // Device toast + chip (0.13.2+) - icon toast on fixture change, then compact chip.
536
+ "#wg-device-toast{position:fixed;z-index:2147483647;top:14px;right:14px;" +
537
+ "display:flex;align-items:center;gap:10px;min-width:200px;max-width:min(92vw,320px);" +
538
+ "padding:12px 14px;border-radius:14px;background:rgba(20,10,40,.96);color:#f0e8ff;" +
539
+ "border:1px solid rgba(124,58,237,.55);box-shadow:0 10px 28px rgba(0,0,0,.4);" +
540
+ "font:600 13px/1.35 system-ui,sans-serif;pointer-events:none;" +
541
+ "opacity:0;transform:translateX(18px) scale(.96);" +
542
+ "transition:opacity .35s ease,transform .45s cubic-bezier(.22,1,.36,1);}" +
543
+ "#wg-device-toast.wg-in{opacity:1;transform:translateX(0) scale(1);}" +
544
+ "#wg-device-toast.wg-out{opacity:0;transform:translateX(12px) scale(.98);}" +
545
+ "#wg-device-toast[data-preset=mobile]{border-color:#3B82F6;}" +
546
+ "#wg-device-toast[data-preset=tablet]{border-color:#22C55E;}" +
547
+ "#wg-device-toast[data-preset=desktop]{border-color:#9CA3AF;}" +
548
+ "#wg-device-toast .wg-dev-icon{flex:0 0 auto;width:36px;height:36px;border-radius:10px;" +
549
+ "display:flex;align-items:center;justify-content:center;background:rgba(124,58,237,.28);}" +
550
+ "#wg-device-toast[data-preset=mobile] .wg-dev-icon{background:rgba(59,130,246,.28);}" +
551
+ "#wg-device-toast[data-preset=tablet] .wg-dev-icon{background:rgba(34,197,94,.28);}" +
552
+ "#wg-device-toast[data-preset=desktop] .wg-dev-icon{background:rgba(156,163,175,.28);}" +
553
+ "#wg-device-toast .wg-dev-icon svg{width:22px;height:22px;display:block;}" +
554
+ "#wg-device-toast .wg-dev-copy{flex:1 1 auto;min-width:0;}" +
555
+ "#wg-device-toast .wg-dev-title{font:800 13px/1.2 system-ui,sans-serif;color:#fff;}" +
556
+ "#wg-device-toast .wg-dev-sub{margin-top:3px;font:600 11px/1.3 system-ui,sans-serif;" +
557
+ "color:#c9a6ff;letter-spacing:.02em;}" +
536
558
  "#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;" +
559
+ "display:inline-flex;align-items:center;gap:6px;padding:6px 10px 6px 8px;" +
560
+ "border-radius:999px;background:rgba(20,10,40,.92);color:#f0e8ff;" +
538
561
  "border:1px solid rgba(124,58,237,.5);font:700 11px/1.2 system-ui,sans-serif;" +
539
562
  "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;}" +
563
+ "box-shadow:0 4px 14px rgba(0,0,0,.3);opacity:0;transform:translateY(-4px);" +
564
+ "transition:opacity .3s ease,transform .35s cubic-bezier(.22,1,.36,1);}" +
565
+ "#wg-device-badge.wg-in{opacity:1;transform:translateY(0);}" +
542
566
  "#wg-device-badge[data-preset=mobile]{border-color:#3B82F6;}" +
543
567
  "#wg-device-badge[data-preset=tablet]{border-color:#22C55E;}" +
544
568
  "#wg-device-badge[data-preset=desktop]{border-color:#9CA3AF;}" +
545
- "#wg-device-badge[data-touch=1]::after{content:' · touch';opacity:.85;}" +
569
+ "#wg-device-badge .wg-dev-icon{width:16px;height:16px;display:flex;align-items:center;justify-content:center;}" +
570
+ "#wg-device-badge .wg-dev-icon svg{width:14px;height:14px;display:block;}" +
571
+ "#wg-device-badge .wg-dev-label{white-space:nowrap;}" +
546
572
  "#wg-cursor[data-touch=1]{width:28px;height:28px;}" +
547
573
  "#wg-todos{list-style:none;margin:0 0 12px;padding:8px 10px;background:#0f0620;border-radius:8px;" +
548
574
  "border:1px solid #3a2a60;}" +
@@ -1287,15 +1313,14 @@ async function installOverlay(page, title) {
1287
1313
  window.__wgStampModal(dock, "todos", { ready: true });
1288
1314
  }
1289
1315
  };
1290
- // Device / touch badge (0.13+) - mirror todo sync keep/set/clear.
1291
- // Payload: { sync, device?, remain? }
1316
+ // Device toast + chip (0.13.2+) - announce on set/clear; quiet chip on keep.
1317
+ // Payload: { sync, device?, remain?, announce? }
1292
1318
  window.__wgSyncDevice = (payload) => {
1293
1319
  const sync =
1294
1320
  payload && (payload.sync === "clear" || payload.sync === "keep" || payload.sync === "set")
1295
1321
  ? payload.sync
1296
1322
  : "keep";
1297
1323
  const device = payload && payload.device ? payload.device : null;
1298
- let badge = document.getElementById("wg-device-badge");
1299
1324
  const cursor = document.getElementById("wg-cursor");
1300
1325
  const MOUSE_SVG =
1301
1326
  "<svg viewBox='0 0 32 32' width='24' height='24'>" +
@@ -1306,43 +1331,186 @@ async function installOverlay(page, title) {
1306
1331
  "<ellipse cx='16' cy='22' rx='7' ry='8' fill='#0C0C1A' stroke='#fff' stroke-width='1.4'/>" +
1307
1332
  "<rect x='12' y='6' width='8' height='16' rx='4' fill='#0C0C1A' stroke='#fff' stroke-width='1.4'/>" +
1308
1333
  "</svg>";
1309
- if (sync === "keep" && !device) return;
1310
- if (sync === "clear" || (device && device.preset === "desktop" && !device.touchMode)) {
1311
- if (badge) badge.remove();
1312
- if (cursor) {
1334
+ const ICONS = {
1335
+ mobile:
1336
+ '<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' +
1337
+ '<rect x="7" y="2.5" width="10" height="19" rx="2.2"/>' +
1338
+ '<circle cx="12" cy="18.2" r="1.1" fill="#fff" stroke="none"/></svg>',
1339
+ tablet:
1340
+ '<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' +
1341
+ '<rect x="3.5" y="4" width="17" height="16" rx="2"/>' +
1342
+ '<circle cx="12" cy="17.2" r="1" fill="#fff" stroke="none"/></svg>',
1343
+ desktop:
1344
+ '<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' +
1345
+ '<rect x="2.5" y="3.5" width="19" height="12.5" rx="1.5"/>' +
1346
+ '<path d="M8 20h8M12 16v4"/></svg>',
1347
+ };
1348
+ const setCursor = (touch) => {
1349
+ if (!cursor) return;
1350
+ if (touch) {
1351
+ cursor.setAttribute("data-touch", "1");
1352
+ cursor.innerHTML = FINGER_SVG;
1353
+ } else {
1313
1354
  cursor.removeAttribute("data-touch");
1314
1355
  cursor.innerHTML = MOUSE_SVG;
1315
1356
  }
1316
- if (sync === "clear" && !device) return;
1317
- // clear with desktop device: still hide badge (back to main)
1318
- if (device && device.preset === "desktop" && !device.touchMode) return;
1357
+ };
1358
+ const ensureChip = (preset, touch, remain, orientation) => {
1359
+ let badge = document.getElementById("wg-device-badge");
1360
+ if (preset === "desktop" && !touch) {
1361
+ if (badge) {
1362
+ badge.classList.remove("wg-in");
1363
+ setTimeout(() => {
1364
+ const b = document.getElementById("wg-device-badge");
1365
+ if (b) b.remove();
1366
+ }, 320);
1367
+ }
1368
+ return;
1369
+ }
1370
+ if (!badge) {
1371
+ badge = document.createElement("div");
1372
+ badge.id = "wg-device-badge";
1373
+ badge.setAttribute("data-wg-ui", "1");
1374
+ document.documentElement.appendChild(badge);
1375
+ }
1376
+ badge.dataset.preset = preset;
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;
1383
+ const chipLabel =
1384
+ preset === "desktop" && !touch
1385
+ ? "desktop"
1386
+ : preset +
1387
+ (orient ? " \u00b7 " + (orient === "landscape" ? "land" : "port") : "") +
1388
+ (remain ? " \u00b7 remain" : "");
1389
+ badge.innerHTML =
1390
+ '<span class="wg-dev-icon">' +
1391
+ (ICONS[preset] || ICONS.desktop) +
1392
+ '</span><span class="wg-dev-label">' +
1393
+ chipLabel +
1394
+ "</span>";
1395
+ void badge.offsetWidth;
1396
+ badge.classList.add("wg-in");
1397
+ };
1398
+ const showToast = (preset, touch, title, sub, orientation) => {
1399
+ let toast = document.getElementById("wg-device-toast");
1400
+ if (toast && toast._wgTimer) {
1401
+ clearTimeout(toast._wgTimer);
1402
+ toast._wgTimer = null;
1403
+ }
1404
+ if (!toast) {
1405
+ toast = document.createElement("div");
1406
+ toast.id = "wg-device-toast";
1407
+ toast.setAttribute("data-wg-ui", "1");
1408
+ document.documentElement.appendChild(toast);
1409
+ }
1410
+ // Hide chip while toast is up (same corner).
1411
+ const badge = document.getElementById("wg-device-badge");
1412
+ if (badge) badge.classList.remove("wg-in");
1413
+ toast.dataset.preset = preset;
1414
+ toast.innerHTML =
1415
+ '<span class="wg-dev-icon">' +
1416
+ (ICONS[preset] || ICONS.desktop) +
1417
+ '</span><span class="wg-dev-copy"><div class="wg-dev-title">' +
1418
+ title +
1419
+ '</div><div class="wg-dev-sub">' +
1420
+ sub +
1421
+ "</div></span>";
1422
+ toast.classList.remove("wg-out");
1423
+ void toast.offsetWidth;
1424
+ toast.classList.add("wg-in");
1425
+ toast._wgTimer = setTimeout(() => {
1426
+ toast.classList.add("wg-out");
1427
+ toast.classList.remove("wg-in");
1428
+ setTimeout(() => {
1429
+ const t = document.getElementById("wg-device-toast");
1430
+ if (t) t.remove();
1431
+ if (preset !== "desktop" || touch) {
1432
+ ensureChip(preset, touch, true, orientation);
1433
+ }
1434
+ }, 380);
1435
+ }, 2200);
1436
+ };
1437
+
1438
+ if (sync === "keep" && !device) return;
1439
+
1440
+ const isDesktopClear =
1441
+ sync === "clear" ||
1442
+ (device && device.preset === "desktop" && !device.touchMode);
1443
+ if (isDesktopClear) {
1444
+ setCursor(false);
1445
+ const announce = payload.announce !== false && sync !== "keep";
1446
+ if (announce) {
1447
+ const vp = device && device.viewport ? device.viewport : { width: 1280, height: 720 };
1448
+ showToast(
1449
+ "desktop",
1450
+ false,
1451
+ "Back to desktop",
1452
+ vp.width + "\u00d7" + vp.height + " \u00b7 mouse",
1453
+ "landscape",
1454
+ );
1455
+ setTimeout(() => {
1456
+ const badge = document.getElementById("wg-device-badge");
1457
+ if (badge) badge.remove();
1458
+ }, 2600);
1459
+ } else {
1460
+ const badge = document.getElementById("wg-device-badge");
1461
+ if (badge) badge.remove();
1462
+ const toast = document.getElementById("wg-device-toast");
1463
+ if (toast) toast.remove();
1464
+ }
1465
+ window.__wgDevicePreset = "desktop";
1466
+ window.__wgDeviceOrientation = "landscape";
1467
+ return;
1319
1468
  }
1469
+
1320
1470
  if (!device) return;
1321
1471
  const preset = device.preset || "desktop";
1322
1472
  const touch = !!device.touchMode;
1323
1473
  const remain = payload.remain !== false && sync !== "clear";
1324
- if (!badge) {
1325
- badge = document.createElement("div");
1326
- badge.id = "wg-device-badge";
1327
- badge.setAttribute("data-wg-ui", "1");
1328
- document.documentElement.appendChild(badge);
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";
1481
+ const prev = window.__wgDevicePreset || "";
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 === "";
1486
+ setCursor(touch);
1487
+ window.__wgDevicePreset = preset;
1488
+ window.__wgDeviceOrientation = orient;
1489
+
1490
+ const announce =
1491
+ payload.announce === true ||
1492
+ (payload.announce !== false && (sync === "set" || sync === "clear") && changed);
1493
+
1494
+ const size =
1495
+ (vp.width || "?") +
1496
+ "\u00d7" +
1497
+ (vp.height || "?") +
1498
+ " \u00b7 " +
1499
+ orient +
1500
+ (touch ? " \u00b7 touch" : "");
1501
+ let titleText = {
1502
+ mobile: "Now in mobile mode",
1503
+ tablet: "Now in tablet mode",
1504
+ desktop: "Now in desktop mode",
1505
+ }[preset] || "Device updated";
1506
+ if (orientChanged && !presetChanged) {
1507
+ titleText =
1508
+ orient === "landscape" ? "Rotated to landscape" : "Rotated to portrait";
1329
1509
  }
1330
- badge.dataset.preset = preset;
1331
- badge.dataset.touch = touch ? "1" : "0";
1332
- const label =
1333
- preset === "desktop" && !touch
1334
- ? "desktop"
1335
- : preset + (remain ? " · remain" : " · set");
1336
- badge.textContent = label;
1337
- badge.classList.add("wg-in");
1338
- if (cursor) {
1339
- if (touch) {
1340
- cursor.setAttribute("data-touch", "1");
1341
- cursor.innerHTML = FINGER_SVG;
1342
- } else {
1343
- cursor.removeAttribute("data-touch");
1344
- cursor.innerHTML = MOUSE_SVG;
1345
- }
1510
+ if (announce) {
1511
+ showToast(preset, touch, titleText, size, orient);
1512
+ } else {
1513
+ ensureChip(preset, touch, remain, orient);
1346
1514
  }
1347
1515
  };
1348
1516
  },
@@ -2776,8 +2944,7 @@ async function clickPulseAt(page, x, y, tone) {
2776
2944
 
2777
2945
  /**
2778
2946
  * Apply authored device fixture to the live page (0.13+).
2779
- * Uses the real setViewportSize (step mode noops block-authored calls).
2780
- * sync keep with no device = no-op; clear / desktop = restore main feel.
2947
+ * Seamless viewport lerp + toast on set/clear (quiet chip recreate on keep).
2781
2948
  */
2782
2949
  async function applyDeviceToPage(page, device, sync) {
2783
2950
  const mode = sync || (device ? "set" : "keep");
@@ -2795,14 +2962,46 @@ async function applyDeviceToPage(page, device, sync) {
2795
2962
  typeof page.__wgRealSetViewportSize === "function"
2796
2963
  ? page.__wgRealSetViewportSize
2797
2964
  : page.setViewportSize.bind(page);
2965
+ const target = {
2966
+ width: Math.max(200, Math.floor(d.viewport.width)),
2967
+ height: Math.max(200, Math.floor(d.viewport.height)),
2968
+ };
2969
+ let from = null;
2798
2970
  try {
2799
- await realSet({
2800
- width: d.viewport.width,
2801
- height: d.viewport.height,
2802
- });
2971
+ from = page.viewportSize();
2803
2972
  } catch {
2804
- /* maximized / null viewport hosts may reject - badge still updates */
2973
+ from = null;
2805
2974
  }
2975
+ const announce = mode === "set" || mode === "clear";
2976
+ const shouldAnimate =
2977
+ announce &&
2978
+ from &&
2979
+ from.width > 0 &&
2980
+ from.height > 0 &&
2981
+ (from.width !== target.width || from.height !== target.height);
2982
+
2983
+ try {
2984
+ if (shouldAnimate) {
2985
+ const steps = 14;
2986
+ const ms = Number(process.env.WAYGRAPH_DEVICE_TRANSITION_MS || 520);
2987
+ const stepMs = Math.max(12, Math.floor(ms / steps));
2988
+ for (let i = 1; i <= steps; i++) {
2989
+ const t = i / steps;
2990
+ const e = t * t * (3 - 2 * t); // smoothstep
2991
+ await realSet({
2992
+ width: Math.round(from.width + (target.width - from.width) * e),
2993
+ height: Math.round(from.height + (target.height - from.height) * e),
2994
+ });
2995
+ if (i < steps) await new Promise((r) => setTimeout(r, stepMs));
2996
+ }
2997
+ } else {
2998
+ await realSet(target);
2999
+ }
3000
+ } catch {
3001
+ /* maximized / null viewport hosts may reject - toast still updates */
3002
+ }
3003
+
3004
+ // Fire toast near the end of the lerp so it reads as one seamless beat.
2806
3005
  await page
2807
3006
  .evaluate(
2808
3007
  (payload) => {
@@ -2812,6 +3011,7 @@ async function applyDeviceToPage(page, device, sync) {
2812
3011
  sync: mode,
2813
3012
  device: d,
2814
3013
  remain: mode === "keep" || mode === "set",
3014
+ announce,
2815
3015
  },
2816
3016
  )
2817
3017
  .catch(() => {});
@@ -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";
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Headed visual demo: mobile -> tablet -> desktop with toast + seamless lerp.
3
+ * Usage (from examples/saucedemo): WG_DEVICE_HOLD_MS=5000 node scripts/demo-device-presets.mjs
4
+ */
5
+ import { chromium } from "playwright";
6
+ import { mkdirSync } from "node:fs";
7
+ import { join, dirname } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ const outDir = join(__dirname, "../.tmp/device-demo");
12
+ mkdirSync(outDir, { recursive: true });
13
+
14
+ const PRESETS = {
15
+ mobile: {
16
+ preset: "mobile",
17
+ viewport: { width: 390, height: 844 },
18
+ touchMode: true,
19
+ title: "Now in mobile mode",
20
+ },
21
+ tablet: {
22
+ preset: "tablet",
23
+ viewport: { width: 768, height: 1024 },
24
+ touchMode: true,
25
+ title: "Now in tablet mode",
26
+ },
27
+ desktop: {
28
+ preset: "desktop",
29
+ viewport: { width: 1280, height: 720 },
30
+ touchMode: false,
31
+ title: "Back to desktop",
32
+ },
33
+ };
34
+
35
+ const CSS = `
36
+ #wg-device-toast{position:fixed;z-index:2147483647;top:14px;right:14px;
37
+ display:flex;align-items:center;gap:10px;min-width:200px;max-width:min(92vw,320px);
38
+ padding:12px 14px;border-radius:14px;background:rgba(20,10,40,.96);color:#f0e8ff;
39
+ border:1px solid rgba(124,58,237,.55);box-shadow:0 10px 28px rgba(0,0,0,.4);
40
+ font:600 13px/1.35 system-ui,sans-serif;pointer-events:none;
41
+ opacity:0;transform:translateX(18px) scale(.96);
42
+ transition:opacity .35s ease,transform .45s cubic-bezier(.22,1,.36,1);}
43
+ #wg-device-toast.wg-in{opacity:1;transform:translateX(0) scale(1);}
44
+ #wg-device-toast.wg-out{opacity:0;transform:translateX(12px) scale(.98);}
45
+ #wg-device-toast[data-preset=mobile]{border-color:#3B82F6;}
46
+ #wg-device-toast[data-preset=tablet]{border-color:#22C55E;}
47
+ #wg-device-toast[data-preset=desktop]{border-color:#9CA3AF;}
48
+ #wg-device-toast .wg-dev-icon{flex:0 0 auto;width:36px;height:36px;border-radius:10px;
49
+ display:flex;align-items:center;justify-content:center;background:rgba(124,58,237,.28);}
50
+ #wg-device-toast[data-preset=mobile] .wg-dev-icon{background:rgba(59,130,246,.28);}
51
+ #wg-device-toast[data-preset=tablet] .wg-dev-icon{background:rgba(34,197,94,.28);}
52
+ #wg-device-toast[data-preset=desktop] .wg-dev-icon{background:rgba(156,163,175,.28);}
53
+ #wg-device-toast .wg-dev-icon svg{width:22px;height:22px;display:block;}
54
+ #wg-device-toast .wg-dev-title{font:800 13px/1.2 system-ui,sans-serif;color:#fff;}
55
+ #wg-device-toast .wg-dev-sub{margin-top:3px;font:600 11px/1.3 system-ui,sans-serif;color:#c9a6ff;}
56
+ #wg-device-badge{position:fixed;z-index:2147483646;top:14px;right:14px;
57
+ display:inline-flex;align-items:center;gap:6px;padding:6px 10px 6px 8px;
58
+ border-radius:999px;background:rgba(20,10,40,.92);color:#f0e8ff;
59
+ border:1px solid rgba(124,58,237,.5);font:700 11px/1.2 system-ui,sans-serif;
60
+ letter-spacing:.04em;text-transform:uppercase;pointer-events:none;
61
+ box-shadow:0 4px 14px rgba(0,0,0,.3);opacity:0;transition:opacity .3s ease;}
62
+ #wg-device-badge.wg-in{opacity:1;}
63
+ #wg-device-badge[data-preset=mobile]{border-color:#3B82F6;}
64
+ #wg-device-badge[data-preset=tablet]{border-color:#22C55E;}
65
+ #wg-device-badge .wg-dev-icon svg{width:14px;height:14px;display:block;}
66
+ `;
67
+
68
+ const ICONS = {
69
+ mobile:
70
+ '<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><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>',
71
+ tablet:
72
+ '<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><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>',
73
+ desktop:
74
+ '<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="3.5" width="19" height="12.5" rx="1.5"/><path d="M8 20h8M12 16v4"/></svg>',
75
+ };
76
+
77
+ async function lerpViewport(page, to, ms = 520) {
78
+ const from = page.viewportSize() || { width: 1280, height: 720 };
79
+ const steps = 14;
80
+ const stepMs = Math.max(12, Math.floor(ms / steps));
81
+ for (let i = 1; i <= steps; i++) {
82
+ const t = i / steps;
83
+ const e = t * t * (3 - 2 * t);
84
+ await page.setViewportSize({
85
+ width: Math.round(from.width + (to.width - from.width) * e),
86
+ height: Math.round(from.height + (to.height - from.height) * e),
87
+ });
88
+ if (i < steps) await page.waitForTimeout(stepMs);
89
+ }
90
+ }
91
+
92
+ async function applyPreset(page, key) {
93
+ const d = PRESETS[key];
94
+ await lerpViewport(page, d.viewport, 520);
95
+ await page.evaluate(
96
+ ({ d, css, icons, key }) => {
97
+ let style = document.getElementById("wg-device-demo-style");
98
+ if (!style) {
99
+ style = document.createElement("style");
100
+ style.id = "wg-device-demo-style";
101
+ style.textContent = css;
102
+ document.documentElement.appendChild(style);
103
+ }
104
+ let toast = document.getElementById("wg-device-toast");
105
+ if (toast && toast._wgTimer) clearTimeout(toast._wgTimer);
106
+ if (!toast) {
107
+ toast = document.createElement("div");
108
+ toast.id = "wg-device-toast";
109
+ document.documentElement.appendChild(toast);
110
+ }
111
+ const badge = document.getElementById("wg-device-badge");
112
+ if (badge) badge.classList.remove("wg-in");
113
+ toast.dataset.preset = d.preset;
114
+ const sub =
115
+ d.viewport.width +
116
+ "\u00d7" +
117
+ d.viewport.height +
118
+ (d.touchMode ? " \u00b7 touch" : " \u00b7 mouse");
119
+ toast.innerHTML =
120
+ '<span class="wg-dev-icon">' +
121
+ icons[d.preset] +
122
+ '</span><span class="wg-dev-copy"><div class="wg-dev-title">' +
123
+ d.title +
124
+ '</div><div class="wg-dev-sub">' +
125
+ sub +
126
+ "</div></span>";
127
+ toast.classList.remove("wg-out");
128
+ void toast.offsetWidth;
129
+ toast.classList.add("wg-in");
130
+ toast._wgTimer = setTimeout(() => {
131
+ toast.classList.add("wg-out");
132
+ toast.classList.remove("wg-in");
133
+ setTimeout(() => {
134
+ if (d.preset === "desktop" && !d.touchMode) {
135
+ const b = document.getElementById("wg-device-badge");
136
+ if (b) b.remove();
137
+ return;
138
+ }
139
+ let chip = document.getElementById("wg-device-badge");
140
+ if (!chip) {
141
+ chip = document.createElement("div");
142
+ chip.id = "wg-device-badge";
143
+ document.documentElement.appendChild(chip);
144
+ }
145
+ chip.dataset.preset = d.preset;
146
+ chip.innerHTML =
147
+ '<span class="wg-dev-icon">' +
148
+ icons[d.preset] +
149
+ '</span><span>' +
150
+ d.preset +
151
+ " \u00b7 remain</span>";
152
+ chip.classList.add("wg-in");
153
+ }, 380);
154
+ }, 2200);
155
+ },
156
+ { d, css: CSS, icons: ICONS, key },
157
+ );
158
+ }
159
+
160
+ const holdMs = Number(process.env.WG_DEVICE_HOLD_MS || 4500);
161
+ const executablePath = process.env.CHROME_PATH || process.env.CHROMIUM_PATH || undefined;
162
+
163
+ const browser = await chromium.launch({
164
+ headless: false,
165
+ ...(executablePath ? { executablePath } : {}),
166
+ });
167
+ const context = await browser.newContext({
168
+ hasTouch: true,
169
+ viewport: { width: 1280, height: 720 },
170
+ });
171
+ const page = await context.newPage();
172
+ await page.goto("https://www.saucedemo.com/", { waitUntil: "domcontentloaded" });
173
+
174
+ const shots = [];
175
+ for (const key of ["mobile", "tablet", "desktop"]) {
176
+ console.log("device demo:", key);
177
+ await applyPreset(page, key);
178
+ await page.waitForTimeout(900);
179
+ const shot = join(outDir, `device-${key}.png`);
180
+ await page.screenshot({ path: shot, fullPage: false });
181
+ shots.push(shot);
182
+ console.log(" shot:", shot);
183
+ await page.waitForTimeout(holdMs);
184
+ }
185
+
186
+ console.log("DEVICE_DEMO_DONE", JSON.stringify(shots));
187
+ await page.waitForTimeout(1500);
188
+ await browser.close();
@@ -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
+ );
@@ -1,4 +1,5 @@
1
1
  import { definePageBlock, Trait } from "waygraph";
2
+ import type { StubCtx } from "waygraph";
2
3
  import type { LoggedIn } from "../../../states/checkout.states.js";
3
4
  import { AddToCartBlock } from "./methods/add-to-cart.effect.block.js";
4
5
  import { RemoveFromCartBlock } from "./methods/remove-from-cart.effect.block.js";
@@ -27,6 +28,11 @@ export const InventoryPageBlock = definePageBlock<LoggedIn>({
27
28
  Trait.url({ pathname: "/inventory.html" }),
28
29
  Trait.visible(InventorySel.list),
29
30
  ],
31
+ // Showcase responsive dashboard: tablet after mobile login (toast + lerp).
32
+ stubBefore: (ctx: StubCtx) => {
33
+ ctx.title("Inventory · tablet");
34
+ ctx.device("tablet");
35
+ },
30
36
  methods: {
31
37
  addToCart: () => AddToCartBlock,
32
38
  removeFromCart: () => RemoveFromCartBlock,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "waygraph",
3
- "version": "0.13.1",
3
+ "version": "0.13.3",
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 { definePageBlock, Trait } from "waygraph";
2
+ import type { StubCtx } from "waygraph";
2
3
  import type { LoggedIn } from "../../../states/checkout.states.js";
3
4
  import { AddToCartBlock } from "./methods/add-to-cart.effect.block.js";
4
5
  import { RemoveFromCartBlock } from "./methods/remove-from-cart.effect.block.js";
@@ -27,6 +28,11 @@ export const InventoryPageBlock = definePageBlock<LoggedIn>({
27
28
  Trait.url({ pathname: "/inventory.html" }),
28
29
  Trait.visible(InventorySel.list),
29
30
  ],
31
+ // Showcase responsive dashboard: tablet after mobile login (toast + lerp).
32
+ stubBefore: (ctx: StubCtx) => {
33
+ ctx.title("Inventory · tablet");
34
+ ctx.device("tablet");
35
+ },
30
36
  methods: {
31
37
  addToCart: () => AddToCartBlock,
32
38
  removeFromCart: () => RemoveFromCartBlock,