waygraph 0.13.0 → 0.13.2

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;}" +
@@ -552,6 +578,9 @@ const RING_CSS =
552
578
  "#wg-todos .wg-todo-done{color:#6ee7b7;text-decoration:line-through;opacity:.85;}" +
553
579
  "#wg-todos .wg-todo-current{color:#fff;}" +
554
580
  "#wg-todos .wg-todo-pending{color:#9a7ad1;}" +
581
+ "#wg-todos[data-wg-todo-style=bullets] .wg-todo-pending," +
582
+ "#wg-todos[data-wg-todo-style=bullets] .wg-todo-current{color:#e8dcff;}" +
583
+ "#wg-todos[data-wg-todo-style=bullets] .wg-todo-mark{color:#c9a6ff;}" +
555
584
  "#wg-progress{height:4px;background:#2a1650;border-radius:2px;margin:0 0 12px;overflow:hidden;}" +
556
585
  "#wg-progress-bar{height:100%;background:#7C3AED;border-radius:2px;transition:width .3s ease;}" +
557
586
  // A real tab bar for episodes - the currently-active episode reads as
@@ -1196,21 +1225,39 @@ async function installOverlay(page, title) {
1196
1225
  return id ? ' data-wg-todo-item="' + esc(id) + '"' : "";
1197
1226
  };
1198
1227
  const renderItems = (items, style) => {
1199
- const st = style === "checklist" ? "checklist" : "sequential";
1228
+ const rawStyle = String(style || "sequential").toLowerCase();
1229
+ const st =
1230
+ rawStyle === "checklist"
1231
+ ? "checklist"
1232
+ : rawStyle === "bullets" ||
1233
+ rawStyle === "bullet" ||
1234
+ rawStyle === "list" ||
1235
+ rawStyle === "ul" ||
1236
+ rawStyle === "points" ||
1237
+ rawStyle === "plain"
1238
+ ? "bullets"
1239
+ : "sequential";
1200
1240
  return (
1201
1241
  '<ul id="wg-todos" data-wg-todo-style="' +
1202
1242
  st +
1203
1243
  '">' +
1204
1244
  (items || [])
1205
1245
  .map((t) => {
1206
- const cls = t.current
1207
- ? "wg-todo-current"
1208
- : t.done
1209
- ? "wg-todo-done"
1210
- : "wg-todo-pending";
1246
+ const cls =
1247
+ st === "bullets"
1248
+ ? t.done
1249
+ ? "wg-todo-done"
1250
+ : "wg-todo-pending"
1251
+ : t.current
1252
+ ? "wg-todo-current"
1253
+ : t.done
1254
+ ? "wg-todo-done"
1255
+ : "wg-todo-pending";
1211
1256
  let mark;
1212
1257
  if (st === "checklist") {
1213
1258
  mark = t.done ? "\u2611" : "\u2610";
1259
+ } else if (st === "bullets") {
1260
+ mark = "\u2022";
1214
1261
  } else {
1215
1262
  mark = t.done ? "\u2713" : t.current ? "\u2192" : "\u25CB";
1216
1263
  }
@@ -1266,15 +1313,14 @@ async function installOverlay(page, title) {
1266
1313
  window.__wgStampModal(dock, "todos", { ready: true });
1267
1314
  }
1268
1315
  };
1269
- // Device / touch badge (0.13+) - mirror todo sync keep/set/clear.
1270
- // 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? }
1271
1318
  window.__wgSyncDevice = (payload) => {
1272
1319
  const sync =
1273
1320
  payload && (payload.sync === "clear" || payload.sync === "keep" || payload.sync === "set")
1274
1321
  ? payload.sync
1275
1322
  : "keep";
1276
1323
  const device = payload && payload.device ? payload.device : null;
1277
- let badge = document.getElementById("wg-device-badge");
1278
1324
  const cursor = document.getElementById("wg-cursor");
1279
1325
  const MOUSE_SVG =
1280
1326
  "<svg viewBox='0 0 32 32' width='24' height='24'>" +
@@ -1285,43 +1331,158 @@ async function installOverlay(page, title) {
1285
1331
  "<ellipse cx='16' cy='22' rx='7' ry='8' fill='#0C0C1A' stroke='#fff' stroke-width='1.4'/>" +
1286
1332
  "<rect x='12' y='6' width='8' height='16' rx='4' fill='#0C0C1A' stroke='#fff' stroke-width='1.4'/>" +
1287
1333
  "</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) {
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 {
1292
1354
  cursor.removeAttribute("data-touch");
1293
1355
  cursor.innerHTML = MOUSE_SVG;
1294
1356
  }
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;
1357
+ };
1358
+ const ensureChip = (preset, touch, remain) => {
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 chipLabel =
1379
+ preset === "desktop" && !touch
1380
+ ? "desktop"
1381
+ : preset + (remain ? " · remain" : "");
1382
+ badge.innerHTML =
1383
+ '<span class="wg-dev-icon">' +
1384
+ (ICONS[preset] || ICONS.desktop) +
1385
+ '</span><span class="wg-dev-label">' +
1386
+ chipLabel +
1387
+ "</span>";
1388
+ void badge.offsetWidth;
1389
+ badge.classList.add("wg-in");
1390
+ };
1391
+ const showToast = (preset, touch, title, sub) => {
1392
+ let toast = document.getElementById("wg-device-toast");
1393
+ if (toast && toast._wgTimer) {
1394
+ clearTimeout(toast._wgTimer);
1395
+ toast._wgTimer = null;
1396
+ }
1397
+ if (!toast) {
1398
+ toast = document.createElement("div");
1399
+ toast.id = "wg-device-toast";
1400
+ toast.setAttribute("data-wg-ui", "1");
1401
+ document.documentElement.appendChild(toast);
1402
+ }
1403
+ // Hide chip while toast is up (same corner).
1404
+ const badge = document.getElementById("wg-device-badge");
1405
+ if (badge) badge.classList.remove("wg-in");
1406
+ toast.dataset.preset = preset;
1407
+ toast.innerHTML =
1408
+ '<span class="wg-dev-icon">' +
1409
+ (ICONS[preset] || ICONS.desktop) +
1410
+ '</span><span class="wg-dev-copy"><div class="wg-dev-title">' +
1411
+ title +
1412
+ '</div><div class="wg-dev-sub">' +
1413
+ sub +
1414
+ "</div></span>";
1415
+ toast.classList.remove("wg-out");
1416
+ void toast.offsetWidth;
1417
+ toast.classList.add("wg-in");
1418
+ toast._wgTimer = setTimeout(() => {
1419
+ toast.classList.add("wg-out");
1420
+ toast.classList.remove("wg-in");
1421
+ setTimeout(() => {
1422
+ const t = document.getElementById("wg-device-toast");
1423
+ if (t) t.remove();
1424
+ if (preset !== "desktop" || touch) {
1425
+ ensureChip(preset, touch, true);
1426
+ }
1427
+ }, 380);
1428
+ }, 2200);
1429
+ };
1430
+
1431
+ if (sync === "keep" && !device) return;
1432
+
1433
+ const isDesktopClear =
1434
+ sync === "clear" ||
1435
+ (device && device.preset === "desktop" && !device.touchMode);
1436
+ if (isDesktopClear) {
1437
+ setCursor(false);
1438
+ const announce = payload.announce !== false && sync !== "keep";
1439
+ if (announce) {
1440
+ const vp = device && device.viewport ? device.viewport : { width: 1280, height: 720 };
1441
+ showToast(
1442
+ "desktop",
1443
+ false,
1444
+ "Back to desktop",
1445
+ vp.width + "\u00d7" + vp.height + " \u00b7 mouse",
1446
+ );
1447
+ setTimeout(() => {
1448
+ const badge = document.getElementById("wg-device-badge");
1449
+ if (badge) badge.remove();
1450
+ }, 2600);
1451
+ } else {
1452
+ const badge = document.getElementById("wg-device-badge");
1453
+ if (badge) badge.remove();
1454
+ const toast = document.getElementById("wg-device-toast");
1455
+ if (toast) toast.remove();
1456
+ }
1457
+ window.__wgDevicePreset = "desktop";
1458
+ return;
1298
1459
  }
1460
+
1299
1461
  if (!device) return;
1300
1462
  const preset = device.preset || "desktop";
1301
1463
  const touch = !!device.touchMode;
1302
1464
  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
- }
1465
+ const prev = window.__wgDevicePreset || "";
1466
+ const changed = prev !== preset || (touch && prev === "desktop");
1467
+ setCursor(touch);
1468
+ window.__wgDevicePreset = preset;
1469
+
1470
+ const announce =
1471
+ payload.announce === true ||
1472
+ (payload.announce !== false && (sync === "set" || sync === "clear") && changed);
1473
+
1474
+ const vp = device.viewport || {};
1475
+ const size =
1476
+ (vp.width || "?") + "\u00d7" + (vp.height || "?") + (touch ? " \u00b7 touch" : "");
1477
+ const titles = {
1478
+ mobile: "Now in mobile mode",
1479
+ tablet: "Now in tablet mode",
1480
+ desktop: "Now in desktop mode",
1481
+ };
1482
+ if (announce) {
1483
+ showToast(preset, touch, titles[preset] || "Device updated", size);
1484
+ } else {
1485
+ ensureChip(preset, touch, remain);
1325
1486
  }
1326
1487
  };
1327
1488
  },
@@ -2755,8 +2916,7 @@ async function clickPulseAt(page, x, y, tone) {
2755
2916
 
2756
2917
  /**
2757
2918
  * 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.
2919
+ * Seamless viewport lerp + toast on set/clear (quiet chip recreate on keep).
2760
2920
  */
2761
2921
  async function applyDeviceToPage(page, device, sync) {
2762
2922
  const mode = sync || (device ? "set" : "keep");
@@ -2774,14 +2934,46 @@ async function applyDeviceToPage(page, device, sync) {
2774
2934
  typeof page.__wgRealSetViewportSize === "function"
2775
2935
  ? page.__wgRealSetViewportSize
2776
2936
  : page.setViewportSize.bind(page);
2937
+ const target = {
2938
+ width: Math.max(200, Math.floor(d.viewport.width)),
2939
+ height: Math.max(200, Math.floor(d.viewport.height)),
2940
+ };
2941
+ let from = null;
2777
2942
  try {
2778
- await realSet({
2779
- width: d.viewport.width,
2780
- height: d.viewport.height,
2781
- });
2943
+ from = page.viewportSize();
2782
2944
  } catch {
2783
- /* maximized / null viewport hosts may reject - badge still updates */
2945
+ from = null;
2784
2946
  }
2947
+ const announce = mode === "set" || mode === "clear";
2948
+ const shouldAnimate =
2949
+ announce &&
2950
+ from &&
2951
+ from.width > 0 &&
2952
+ from.height > 0 &&
2953
+ (from.width !== target.width || from.height !== target.height);
2954
+
2955
+ try {
2956
+ if (shouldAnimate) {
2957
+ const steps = 14;
2958
+ const ms = Number(process.env.WAYGRAPH_DEVICE_TRANSITION_MS || 520);
2959
+ const stepMs = Math.max(12, Math.floor(ms / steps));
2960
+ for (let i = 1; i <= steps; i++) {
2961
+ const t = i / steps;
2962
+ const e = t * t * (3 - 2 * t); // smoothstep
2963
+ await realSet({
2964
+ width: Math.round(from.width + (target.width - from.width) * e),
2965
+ height: Math.round(from.height + (target.height - from.height) * e),
2966
+ });
2967
+ if (i < steps) await new Promise((r) => setTimeout(r, stepMs));
2968
+ }
2969
+ } else {
2970
+ await realSet(target);
2971
+ }
2972
+ } catch {
2973
+ /* maximized / null viewport hosts may reject - toast still updates */
2974
+ }
2975
+
2976
+ // Fire toast near the end of the lerp so it reads as one seamless beat.
2785
2977
  await page
2786
2978
  .evaluate(
2787
2979
  (payload) => {
@@ -2791,6 +2983,7 @@ async function applyDeviceToPage(page, device, sync) {
2791
2983
  sync: mode,
2792
2984
  device: d,
2793
2985
  remain: mode === "keep" || mode === "set",
2986
+ announce,
2794
2987
  },
2795
2988
  )
2796
2989
  .catch(() => {});
@@ -51,8 +51,9 @@ export type WaygraphTodoInput = string | WaygraphTodoItem;
51
51
  * Dock list presentation:
52
52
  * - `sequential` - arrow on current, strike done (demo walkthrough)
53
53
  * - `checklist` - checkbox done/pending only (no current arrow; independent checks)
54
+ * - `bullets` - flat bullet list (no arrow/checkbox; open notes / trimmed dock)
54
55
  */
55
- export type TodoListStyle = "sequential" | "checklist";
56
+ export type TodoListStyle = "sequential" | "checklist" | "bullets";
56
57
  /** One titled group in the floating todo dock (FR / Scenarios / ACs, etc.). */
57
58
  export type WaygraphTodoGroupInput = {
58
59
  /** Stable group id -> `data-wg-todo-group`. */
@@ -99,7 +100,7 @@ export type StubPhaseFixtures = {
99
100
  * (e.g. Mailhog -> back to app and still find the checklist).
100
101
  */
101
102
  todoId?: string;
102
- /** `sequential` (default) or `checklist`. */
103
+ /** `sequential` (default), `checklist`, or `bullets`. */
103
104
  todoStyle?: TodoListStyle;
104
105
  /** Multiple titled lists (FR / Scenarios / ACs). Wins over bare todos when set. */
105
106
  todoGroups?: readonly WaygraphTodoGroupInput[];
@@ -280,8 +281,8 @@ export type StubCtx<Out extends Checkpoint<string> = Checkpoint<string>> = {
280
281
  */
281
282
  todoId(id: string): void;
282
283
  /**
283
- * Dock presentation: `sequential` (arrow walkthrough) or `checklist`
284
- * (independent checkboxes, no current arrow).
284
+ * Dock presentation: `sequential` (arrow walkthrough), `checklist`
285
+ * (independent checkboxes), or `bullets` (flat • list, no progress marks).
285
286
  */
286
287
  todoStyle(style: TodoListStyle): void;
287
288
  /**
@@ -510,7 +511,8 @@ export declare function normalizeTodoPos(raw: unknown): "left" | "right" | undef
510
511
  export declare function normalizeTodoStyle(raw: unknown): TodoListStyle | undefined;
511
512
  /**
512
513
  * Normalize checklist rows; apply todoIndex for sequential done/current.
513
- * In checklist mode, todoIndex is ignored unless items omit explicit done.
514
+ * In checklist / bullets mode, todoIndex is ignored unless items omit explicit done
515
+ * (bullets never auto-mark current).
514
516
  */
515
517
  export declare function normalizeTodos(todos: readonly WaygraphTodoInput[] | undefined, todoIndex?: number, style?: TodoListStyle): WaygraphTodoItem[];
516
518
  /** Build the floating dock from bag / phase fields. */
@@ -540,13 +542,13 @@ export declare function applyTodoPhase(prev: TodoDockState | undefined, phase: {
540
542
  sync: "set" | "clear" | "keep";
541
543
  };
542
544
  /**
543
- * Advance every sequential group to `index` (capped). Checklist groups unchanged.
545
+ * Advance every sequential group to `index` (capped). Checklist / bullets unchanged.
544
546
  * Used while cycling rings / fill/click stubs so the walkthrough moves.
545
547
  */
546
548
  export declare function advanceTodoDock(dock: TodoDockState | undefined, index: number): TodoDockState | undefined;
547
549
  /**
548
550
  * Mark sequential groups complete (all done, none current) - e.g. end of step.
549
- * Checklist groups keep explicit done flags.
551
+ * Checklist / bullets groups keep explicit done flags.
550
552
  */
551
553
  export declare function completeSequentialTodoDock(dock: TodoDockState | undefined): TodoDockState | undefined;
552
554
  /**
@@ -495,11 +495,20 @@ export function normalizeTodoStyle(raw) {
495
495
  return "sequential";
496
496
  if (s === "checklist" || s === "check" || s === "checks" || s === "box")
497
497
  return "checklist";
498
+ if (s === "bullets" ||
499
+ s === "bullet" ||
500
+ s === "list" ||
501
+ s === "ul" ||
502
+ s === "points" ||
503
+ s === "plain") {
504
+ return "bullets";
505
+ }
498
506
  return undefined;
499
507
  }
500
508
  /**
501
509
  * Normalize checklist rows; apply todoIndex for sequential done/current.
502
- * In checklist mode, todoIndex is ignored unless items omit explicit done.
510
+ * In checklist / bullets mode, todoIndex is ignored unless items omit explicit done
511
+ * (bullets never auto-mark current).
503
512
  */
504
513
  export function normalizeTodos(todos, todoIndex, style = "sequential") {
505
514
  if (!todos || todos.length === 0)
@@ -523,6 +532,11 @@ export function normalizeTodos(todos, todoIndex, style = "sequential") {
523
532
  item.current = false;
524
533
  return item;
525
534
  }
535
+ if (style === "bullets") {
536
+ // Flat list: no walkthrough current; keep explicit done if authored.
537
+ item.current = false;
538
+ return item;
539
+ }
526
540
  if (idx !== undefined) {
527
541
  if (item.done === undefined)
528
542
  item.done = i < idx;
@@ -606,7 +620,7 @@ export function applyTodoPhase(prev, phase) {
606
620
  return { dock: prev, sync: "keep" };
607
621
  }
608
622
  /**
609
- * Advance every sequential group to `index` (capped). Checklist groups unchanged.
623
+ * Advance every sequential group to `index` (capped). Checklist / bullets unchanged.
610
624
  * Used while cycling rings / fill/click stubs so the walkthrough moves.
611
625
  */
612
626
  export function advanceTodoDock(dock, index) {
@@ -636,7 +650,7 @@ export function advanceTodoDock(dock, index) {
636
650
  }
637
651
  /**
638
652
  * Mark sequential groups complete (all done, none current) - e.g. end of step.
639
- * Checklist groups keep explicit done flags.
653
+ * Checklist / bullets groups keep explicit done flags.
640
654
  */
641
655
  export function completeSequentialTodoDock(dock) {
642
656
  if (!dock)
@@ -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();
@@ -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.0",
3
+ "version": "0.13.2",
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,