waygraph 0.15.39 → 0.15.41

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.
@@ -1,4 +1,4 @@
1
- import type { BannerPos, BannerUiOpts, DevicePreset, DeviceState, HighlightTone, TodoDockUiOpts } from "../highlights.js";
1
+ import type { BannerPos, BannerUiOpts, DevicePreset, DeviceState, HighlightTone, TodoDockUiOpts, TodoListStyle, WaygraphTodoGroupInput } from "../highlights.js";
2
2
  import type { Page } from "@playwright/test";
3
3
  /** One ring the Pilot agent wants painted (same fields as demo stub rings). */
4
4
  export type PilotFixtureRing = {
@@ -40,6 +40,10 @@ export type PilotHighlightFixtures = {
40
40
  todoTitle?: string;
41
41
  /** Todo dock side. Default `right`. */
42
42
  todoPos?: "left" | "right";
43
+ /** Presentation: `sequential` (default, arrow walkthrough) | `checklist` | `bullets`. Same as ctx.todoStyle. */
44
+ todoStyle?: TodoListStyle;
45
+ /** Multiple titled lists (FR/Scenarios/ACs, same as ctx.todoGroups) - replaces {@link todos} when set. */
46
+ todoGroups?: readonly WaygraphTodoGroupInput[];
43
47
  /**
44
48
  * Todo-dock UX (compact / collision / behind-ring). Defaults smart-on;
45
49
  * pass false fields to opt out. See {@link TodoDockUiOpts}.
@@ -1,5 +1,5 @@
1
1
  // Split out of the former 950-line pilot-overlay.ts (see src/ARCHITECTURE.md). Behavior unchanged.
2
- import { formatHighlightCaption, normalizeHighlightSize, normalizeHighlightTone, normalizeHighlightWeight, resolveDeviceState, resolveTodoDockUi } from "../highlights.js";
2
+ import { buildTodoDock, formatHighlightCaption, normalizeHighlightSize, normalizeHighlightTone, normalizeHighlightWeight, resolveDeviceState, resolveTodoDockUi } from "../highlights.js";
3
3
  import { ensureInstalled } from "./shell.js";
4
4
  /**
5
5
  * Paint (or clear) agent-authored highlight fixtures on the live page.
@@ -38,11 +38,24 @@ export async function showPilotFixtures(page, fixtures) {
38
38
  focus: r.focus === true,
39
39
  };
40
40
  });
41
- const todos = fixtures.clear ? [] : (fixtures.todos ?? []);
42
41
  const todoIndex = fixtures.todoIndex ?? 0;
43
42
  const todoTitle = fixtures.todoTitle ?? "Plan";
44
43
  const todoPos = fixtures.todoPos === "left" ? "left" : "right";
45
44
  const todoUi = resolveTodoDockUi(fixtures.todoUi ?? null);
45
+ // Real TodoDockState (groups/style), same builder the demo runner uses - not a flat string[]
46
+ // re-implementation, so a Block/agent that authors ctx.todoGroups()/ctx.todoStyle() looks the
47
+ // same on Pilot as it does in `waygraph demo`.
48
+ const todoDock = fixtures.clear
49
+ ? undefined
50
+ : buildTodoDock({
51
+ todos: fixtures.todos,
52
+ todoIndex,
53
+ todoTitle,
54
+ todoStyle: fixtures.todoStyle,
55
+ todoGroups: fixtures.todoGroups,
56
+ todoPos,
57
+ });
58
+ const todoGroups = todoDock?.groups ?? [];
46
59
  // Effective zoom: phase zoom, else last ring that authored zoom.
47
60
  let zoom = fixtures.clear === true
48
61
  ? 1
@@ -70,34 +83,29 @@ export async function showPilotFixtures(page, fixtures) {
70
83
  if (hit?.zoomOut !== undefined)
71
84
  zoomOut = hit.zoomOut !== false;
72
85
  }
73
- // Device viewport (Node-side) before paint so rings land on the new size.
86
+ // Device viewport - same runner/device-stage.js + inpage/device.js every other surface uses
87
+ // (toast + persistent chip + touch mode), not a separate, simpler Pilot-only chip.
74
88
  if (fixtures.clear === true) {
75
- // Leave viewport alone on clear - only drop overlays.
89
+ const { applyDeviceToPage } = await import("../runner/device-stage.js");
90
+ await applyDeviceToPage(page, undefined, "clear").catch(() => { });
76
91
  }
77
92
  else if (fixtures.device !== undefined) {
78
93
  const d = resolveDeviceState(fixtures.device);
79
94
  if (d) {
80
- await page
81
- .setViewportSize({
82
- width: Math.max(200, Math.floor(d.viewport.width)),
83
- height: Math.max(200, Math.floor(d.viewport.height)),
84
- })
85
- .catch(() => { });
95
+ const [{ applyDeviceToPage }, { installDevice }] = await Promise.all([
96
+ import("../runner/device-stage.js"),
97
+ import("../runner/inpage/device.js"),
98
+ ]);
99
+ await page.evaluate(installDevice, {}).catch(() => { });
100
+ await applyDeviceToPage(page, d, "set").catch(() => { });
86
101
  }
87
102
  }
88
- const deviceLabel = fixtures.clear === true
89
- ? ""
90
- : fixtures.device === undefined
91
- ? ""
92
- : typeof fixtures.device === "string"
93
- ? fixtures.device
94
- : fixtures.device.preset || "device";
95
103
  const bannerTitle = fixtures.clear ? undefined : fixtures.title;
96
104
  const bannerPos = fixtures.clear ? undefined : fixtures.titlePos;
97
105
  const bannerHidden = !!fixtures.clear || !!fixtures.bannerUi?.hidden;
98
106
  const bannerCollision = fixtures.bannerUi?.collision !== false;
99
107
  return page
100
- .evaluate(({ rings, todos, todoIndex, todoTitle, todoPos, holdMs, zoom, zoomSelector, zoomOut, clearAll, deviceLabel, todoUi, bannerTitle, bannerPos, bannerHidden, bannerCollision, }) => {
108
+ .evaluate(({ rings, todoGroups, todoPos, holdMs, zoom, zoomSelector, zoomOut, clearAll, todoUi, bannerTitle, bannerPos, bannerHidden, bannerCollision, }) => {
101
109
  // Same #wg-banner element/CSS the demo runner paints (ui/css/banner.css, already in the
102
110
  // shared bundle) - so ctx.title / ctx.titlePos / ctx.bannerUi behave identically on a Pilot
103
111
  // session, not a separate re-implementation.
@@ -178,16 +186,6 @@ export async function showPilotFixtures(page, fixtures) {
178
186
  root.style.removeProperty("transition");
179
187
  __wgById("wg-pilot-fx-zoom")?.remove();
180
188
  };
181
- const setDeviceChip = (label) => {
182
- __wgById("wg-pilot-fx-device")?.remove();
183
- if (!label)
184
- return;
185
- const chip = document.createElement("div");
186
- chip.id = "wg-pilot-fx-device";
187
- chip.dataset.preset = label;
188
- chip.textContent = `device \u00b7 ${label}`;
189
- __wgAdd(chip);
190
- };
191
189
  w.__wgPilotFxZoomOutOnHide = zoomOut;
192
190
  const clearFx = () => {
193
191
  __wgQA(".wg-pilot-fx-ring, .wg-pilot-fx-label").forEach((el) => el.remove());
@@ -196,7 +194,6 @@ export async function showPilotFixtures(page, fixtures) {
196
194
  if (w.__wgPilotFxZoomOutOnHide !== false || clearAll)
197
195
  clearZoom();
198
196
  if (clearAll) {
199
- __wgById("wg-pilot-fx-device")?.remove();
200
197
  clearZoom();
201
198
  }
202
199
  };
@@ -261,42 +258,59 @@ export async function showPilotFixtures(page, fixtures) {
261
258
  focusBox = { x: rect.left, y: rect.top, width: rect.width, height: rect.height };
262
259
  }
263
260
  }
264
- if (todos.length > 0) {
261
+ // Real TodoDockState groups (same shape/builder the demo runner uses) - a group's own
262
+ // style (sequential/checklist/bullets) and its own done/current flags drive rendering here,
263
+ // not a re-derived index over a flattened string list.
264
+ const flat = todoGroups.flatMap((g) => g.items);
265
+ if (flat.length > 0) {
265
266
  const dock = document.createElement("div");
266
267
  dock.id = "wg-pilot-fx-todos";
267
268
  dock.dataset.pos = todoPos;
268
- const title = document.createElement("div");
269
- title.className = "wg-pilot-fx-todo-title";
270
- title.textContent = todoTitle;
271
- dock.appendChild(title);
272
269
  const WINDOW = todoUi.cap > 0 ? todoUi.cap : 5;
273
- const compact = todoUi.compact !== false && todos.length > WINDOW;
270
+ const compact = todoUi.compact !== false && flat.length > WINDOW;
274
271
  if (compact)
275
272
  dock.dataset.compact = "1";
273
+ const currentFlat = Math.max(0, flat.findIndex((it) => it.current));
276
274
  let start = 0;
277
- let end = todos.length;
275
+ let end = flat.length;
278
276
  if (compact) {
279
- start = Math.max(0, todoIndex - Math.floor((WINDOW - 1) / 2));
280
- end = Math.min(todos.length, start + WINDOW);
277
+ start = Math.max(0, currentFlat - Math.floor((WINDOW - 1) / 2));
278
+ end = Math.min(flat.length, start + WINDOW);
281
279
  start = Math.max(0, end - WINDOW);
282
280
  }
283
- const ol = document.createElement("ol");
284
- todos.forEach((text, i) => {
285
- const li = document.createElement("li");
286
- li.textContent = text;
287
- if (i < todoIndex)
288
- li.className = "wg-pilot-fx-todo-done";
289
- else if (i === todoIndex)
290
- li.className = "wg-pilot-fx-todo-now";
291
- if (compact && (i < start || i >= end))
292
- li.classList.add("wg-todo-fold");
293
- ol.appendChild(li);
294
- });
295
- dock.appendChild(ol);
296
- if (compact && todos.length - (end - start) > 0) {
281
+ let flatIdx = 0;
282
+ let shown = 0;
283
+ for (const group of todoGroups) {
284
+ if (group.items.length === 0)
285
+ continue;
286
+ if (group.title) {
287
+ const title = document.createElement("div");
288
+ title.className = "wg-pilot-fx-todo-title";
289
+ title.textContent = group.title;
290
+ dock.appendChild(title);
291
+ }
292
+ const ol = document.createElement("ol");
293
+ const marker = group.style === "checklist" ? (done) => (done ? "\u2611 " : "\u2610 ") : group.style === "bullets" ? () => "\u2022 " : () => "";
294
+ for (const item of group.items) {
295
+ const i = flatIdx++;
296
+ const li = document.createElement("li");
297
+ li.textContent = marker(!!item.done) + item.text;
298
+ if (item.done)
299
+ li.className = "wg-pilot-fx-todo-done";
300
+ else if (item.current)
301
+ li.className = "wg-pilot-fx-todo-now";
302
+ if (compact && (i < start || i >= end))
303
+ li.classList.add("wg-todo-fold");
304
+ else
305
+ shown++;
306
+ ol.appendChild(li);
307
+ }
308
+ dock.appendChild(ol);
309
+ }
310
+ if (compact && flat.length - shown > 0) {
297
311
  const more = document.createElement("div");
298
312
  more.className = "wg-todo-more";
299
- more.textContent = "+" + (todos.length - (end - start)) + " more";
313
+ more.textContent = "+" + (flat.length - shown) + " more";
300
314
  dock.appendChild(more);
301
315
  }
302
316
  __wgAdd(dock);
@@ -342,25 +356,20 @@ export async function showPilotFixtures(page, fixtures) {
342
356
  else if (zoom > 1.001) {
343
357
  setZoomBadge(zoom);
344
358
  }
345
- if (deviceLabel)
346
- setDeviceChip(deviceLabel);
347
359
  if (holdMs > 0 &&
348
- (painted > 0 || todos.length > 0 || zoom > 1.001 || !!deviceLabel)) {
360
+ (painted > 0 || todoGroups.some((g) => g.items.length > 0) || zoom > 1.001)) {
349
361
  w.__wgPilotFxHideTimer = setTimeout(() => clearFx(), holdMs);
350
362
  }
351
363
  return { painted, missing };
352
364
  }, {
353
365
  rings,
354
- todos,
355
- todoIndex,
356
- todoTitle,
366
+ todoGroups,
357
367
  todoPos,
358
368
  holdMs,
359
369
  zoom,
360
370
  zoomSelector: zoomSelector ?? "",
361
371
  zoomOut,
362
372
  clearAll: fixtures.clear === true,
363
- deviceLabel,
364
373
  todoUi,
365
374
  bannerTitle,
366
375
  bannerPos,
@@ -149,6 +149,10 @@ export async function installPersistentPilotOverlay(context) {
149
149
  export async function ensureInstalled(page) {
150
150
  await ensureShadowRoot(page);
151
151
  await page.evaluate(installPilotOverlayShell, OVERLAY_CSS).catch(() => { });
152
+ // Device toast/chip CSS (runner/overlay-css.js's RUNNER_EXTRA_CSS) - same rules the demo runner
153
+ // uses, reused here (not re-authored) so a device fixture looks identical on Pilot.
154
+ const { RUNNER_EXTRA_CSS } = await import("../runner/overlay-css.js");
155
+ await page.evaluate(([css, key]) => __wgCss(css, key), [RUNNER_EXTRA_CSS, "wg-runner-css"]).catch(() => { });
152
156
  }
153
157
  /**
154
158
  * Bottom-left transient toast, separate from the persistent bottom-right
@@ -1,4 +1,4 @@
1
- export type PracticeKind = "wildcard-checkpoint" | "assert-no-type-arg" | "multi-input" | "combined-action" | "empty-verify";
1
+ export type PracticeKind = "wildcard-checkpoint" | "assert-no-type-arg" | "multi-input" | "combined-action" | "empty-verify" | "overcomplex-selector" | "inline-selector-should-use-sel-file";
2
2
  export interface PracticeWarning {
3
3
  kind: PracticeKind;
4
4
  file: string;
@@ -3,8 +3,8 @@
3
3
  * that compile but erode the typed graph. Warnings only (never fail exit code)
4
4
  * unless paired with `tsc` errors in `waygraph typecheck`.
5
5
  */
6
- import { readFileSync } from "node:fs";
7
- import { relative } from "node:path";
6
+ import { readFileSync, readdirSync } from "node:fs";
7
+ import { dirname, relative } from "node:path";
8
8
  /** define*Block<…Checkpoint<string>…> — prefer project Checkpoint types. */
9
9
  const WILDCARD_CHECKPOINT = /\bdefine(?:Method|Effect|Page|Nav|MemNav|NavClick|Action)Block\s*<[^>]*\bCheckpoint\s*<\s*string\s*>/;
10
10
  /** defineAssertBlock({ … }) with no explicit Out type — defaults to wildcard. */
@@ -23,6 +23,19 @@ const EMPTY_VERIFY_ARRAY = /\bverify\s*:\s*\[\s*\]/;
23
23
  /** Any `verify:` key at all, empty or not. */
24
24
  const HAS_VERIFY_KEY = /\bverify\s*:/;
25
25
  const SCAN_GLOB = /\.(block|flow)\.ts$/;
26
+ /**
27
+ * A selector string reaching for a regex-based Playwright text pseudo-class (`:has-text(/…/)`,
28
+ * `:text-matches(…)`) or a plain XPath - real, recurring pattern: an LLM asked to target a simple
29
+ * element (one class, one id, one data-attribute) instead writes a regex/XPath from scratch. Not a
30
+ * type error (Playwright's own selector syntax accepts all of this), so nothing else catches it.
31
+ */
32
+ const TEXT_MATCHES_PSEUDO = /:text-matches\(/;
33
+ const HAS_TEXT_REGEX_PSEUDO = /:has-text\(\s*\//;
34
+ const XPATH_LOCATOR = /\.locator\(\s*["'`](?:xpath=)?\/\//;
35
+ /** A selector string literal inline in a Block file (`.locator("…")`, `click: "…"`, `url: "…"`) -
36
+ * scoped to simple, single-token selectors (id/class/attr/tag) so this doesn't fire on the
37
+ * legitimate multi-part selectors `.sel.ts` files themselves are made of. */
38
+ const INLINE_SIMPLE_SELECTOR = /(?:\.locator|\bclick|\burl)\s*[:(]\s*["'`](#|\.)[\w-]+["'`]/;
26
39
  const METHOD_OR_EFFECT_FILE = /\.(method|effect)\.block\.ts$/;
27
40
  const METHOD_OR_EFFECT_DEFINE = /\bdefine(?:Method|Effect)Block\b/;
28
41
  /** Whole-file opt-out: `// waygraph-ignore-practices` */
@@ -107,6 +120,31 @@ export function collectPracticeWarnings(projectDir, walkDir, opts) {
107
120
  });
108
121
  }
109
122
  }
123
+ if (!isIgnored("overcomplex-selector", ignore) &&
124
+ (TEXT_MATCHES_PSEUDO.test(src) || HAS_TEXT_REGEX_PSEUDO.test(src) || XPATH_LOCATOR.test(src))) {
125
+ const via = TEXT_MATCHES_PSEUDO.test(src)
126
+ ? ":text-matches(...)"
127
+ : HAS_TEXT_REGEX_PSEUDO.test(src)
128
+ ? ":has-text(/regex/)"
129
+ : "an XPath locator";
130
+ warnings.push({
131
+ kind: "overcomplex-selector",
132
+ file: rel,
133
+ detail: `uses ${via} — reach for a plain CSS selector (id/class/data-attribute, via a .sel.ts ` +
134
+ "export) instead of a regex/XPath match unless the DOM genuinely has no stable selector",
135
+ });
136
+ }
137
+ if (!isIgnored("inline-selector-should-use-sel-file", ignore) && INLINE_SIMPLE_SELECTOR.test(src)) {
138
+ const siblingSelFile = readdirSync(dirname(file)).some((f) => f.endsWith(".sel.ts"));
139
+ if (siblingSelFile) {
140
+ warnings.push({
141
+ kind: "inline-selector-should-use-sel-file",
142
+ file: rel,
143
+ detail: "a selector string is inlined here, but this directory already has a *.sel.ts file — " +
144
+ "add/reuse the selector there instead of a new one-off literal",
145
+ });
146
+ }
147
+ }
110
148
  if (!isIgnored("empty-verify", ignore) && IS_ASSERT_BLOCK.test(src) && EMPTY_VERIFY_ARRAY.test(src)) {
111
149
  warnings.push({
112
150
  kind: "empty-verify",
@@ -130,6 +168,10 @@ export function practiceKindLabel(kind) {
130
168
  return "fill and click in one Block";
131
169
  case "empty-verify":
132
170
  return "empty verify — transition unconfirmed";
171
+ case "overcomplex-selector":
172
+ return "regex/XPath selector where a plain one would do";
173
+ case "inline-selector-should-use-sel-file":
174
+ return "inline selector — a .sel.ts file already exists here";
133
175
  default: {
134
176
  const _exhaustive = kind;
135
177
  return _exhaustive;
@@ -0,0 +1,9 @@
1
+ import type { Page } from "@playwright/test";
2
+ import type { DeviceState } from "../highlights.js";
3
+
4
+ export function applyDeviceToPage(
5
+ page: Page,
6
+ device: DeviceState | undefined,
7
+ sync: "set" | "clear" | "keep",
8
+ ): Promise<void>;
9
+ export function applyVideoDeviceStage(page: Page, shell: unknown, viewport: unknown, opts?: unknown): Promise<void>;
@@ -0,0 +1,3 @@
1
+ /** In-page installer (page.evaluate target) - see device.js's own header. Args are accepted for
2
+ * signature-consistency with the other inpage/* installers; only defines window.__wgSyncDevice. */
3
+ export function installDevice(args: Record<string, unknown>): void;
@@ -0,0 +1,3 @@
1
+ export const RUNNER_EXTRA_CSS: string;
2
+ export const HOST_CSS: string;
3
+ export const WAYGRAPH_FAVICON: string;
@@ -0,0 +1,6 @@
1
+ [11:37:39.688] waygraph pilot: stub.before ctx.title("Signing in · mobile")
2
+ [11:37:39.688] waygraph pilot: stub.before ctx.todos n=6 index=0
3
+ [11:37:39.688] waygraph pilot: stub.before ctx.device(mobile)
4
+ [11:37:39.688] waygraph pilot: stub.before ctx.zoom(1.35)
5
+ [11:37:39.688] waygraph pilot: stub.before ring #user-name tone=info label="Username"
6
+ [11:37:41.397] waygraph pilot: stub.after ctx.todos n=6 index=1
@@ -0,0 +1 @@
1
+ [11:36:30.657] waygraph pilot: highlight ctx.todos n=3 index=1
File without changes
File without changes
@@ -0,0 +1,2 @@
1
+ [11:33:28.909] waygraph pilot: highlight ctx.device(mobile)
2
+ [11:33:28.910] waygraph pilot: highlight ring #user-name tone=planned label="U"
@@ -0,0 +1,5 @@
1
+ [11:36:57.309] waygraph pilot: highlight ctx.todos n=3 index=0
2
+ [11:36:57.309] waygraph pilot: highlight ring #user-name tone=planned label="Username"
3
+ [11:36:57.310] waygraph pilot: highlight ring #password tone=info label="Password"
4
+ [11:37:00.472] waygraph pilot: highlight ring #no-such-element-waygraph tone=danger label="Gone"
5
+ [11:37:04.621] waygraph pilot: highlight ring #login-button tone=success label="Login"
@@ -0,0 +1,20 @@
1
+ [11:37:16.027] waygraph pilot: stub.before ctx.title("Signing in · mobile")
2
+ [11:37:16.027] waygraph pilot: stub.before ctx.todos n=6 index=0
3
+ [11:37:16.027] waygraph pilot: stub.before ctx.device(mobile)
4
+ [11:37:16.027] waygraph pilot: stub.before ctx.zoom(1.35)
5
+ [11:37:16.027] waygraph pilot: stub.before ring #user-name tone=info label="Username"
6
+ [11:37:17.582] waygraph pilot: stub.after ctx.todos n=6 index=1
7
+ [11:37:19.508] waygraph pilot: stub.before ring #password tone=planned label="Password"
8
+ [11:37:19.699] waygraph pilot: stub.after ctx.todos n=6 index=2
9
+ [11:37:21.435] waygraph pilot: stub.before ring #login-button tone=warning label="Login"
10
+ [11:37:21.669] waygraph pilot: stub.after ctx.title("Inventory · landscape")
11
+ [11:37:21.669] waygraph pilot: stub.after ctx.todos n=6 index=3
12
+ [11:37:21.669] waygraph pilot: stub.after ctx.device(mobile)
13
+ [11:37:21.669] waygraph pilot: stub.after ring .inventory_list tone=success label="Product shelf"
14
+ [11:37:25.741] waygraph pilot: stub.before ctx.title("Open cart · landscape")
15
+ [11:37:25.741] waygraph pilot: stub.before ctx.todos n=6 index=5
16
+ [11:37:25.741] waygraph pilot: stub.before ctx.device(mobile)
17
+ [11:37:25.741] waygraph pilot: stub.before ctx.zoom(1.4)
18
+ [11:37:25.741] waygraph pilot: stub.before ring .shopping_cart_link tone=planned label="Cart"
19
+ [11:37:26.102] waygraph pilot: stub.after ctx.title("Your cart · desktop")
20
+ [11:37:26.102] waygraph pilot: stub.after ring .cart_item tone=success label="Cart items"
@@ -0,0 +1,22 @@
1
+ [11:37:25.914] waygraph pilot: stub.before ctx.title("Signing in · mobile")
2
+ [11:37:25.915] waygraph pilot: stub.before ctx.todos n=6 index=0
3
+ [11:37:25.915] waygraph pilot: stub.before ctx.device(mobile)
4
+ [11:37:25.915] waygraph pilot: stub.before ctx.zoom(1.35)
5
+ [11:37:25.915] waygraph pilot: stub.before ring #user-name tone=info label="Username"
6
+ [11:37:27.864] waygraph pilot: stub.after ctx.todos n=6 index=1
7
+ [11:37:31.358] waygraph pilot: stub.before ring #password tone=planned label="Password"
8
+ [11:37:31.685] waygraph pilot: stub.after ctx.todos n=6 index=2
9
+ [11:37:35.206] waygraph pilot: stub.before ring #login-button tone=warning label="Login"
10
+ [11:37:35.729] waygraph pilot: stub.after ctx.title("Inventory · landscape")
11
+ [11:37:35.729] waygraph pilot: stub.after ctx.todos n=6 index=3
12
+ [11:37:35.729] waygraph pilot: stub.after ctx.device(mobile)
13
+ [11:37:35.729] waygraph pilot: stub.after ring .inventory_list tone=success label="Product shelf"
14
+ [11:37:43.407] waygraph pilot: stub.before ctx.title("Pick a product · tablet")
15
+ [11:37:43.407] waygraph pilot: stub.before ctx.todos n=6 index=3
16
+ [11:37:43.407] waygraph pilot: stub.before ctx.device(tablet)
17
+ [11:37:43.407] waygraph pilot: stub.before ctx.zoom(1.35)
18
+ [11:37:43.407] waygraph pilot: stub.before ring div.inventory_item:has(button[data-test="add-to-cart-sauce-labs-backpack"], button[data-test="remove-sauce-labs-backpack"]) .inventory_item_name tone=info label="Sauce Labs Backpack"
19
+ [11:37:43.407] waygraph pilot: stub.before ring [data-test="add-to-cart-sauce-labs-backpack"] tone=success label="Add to cart"
20
+ [11:37:44.906] waygraph pilot: stub.after ctx.title("In the cart")
21
+ [11:37:44.906] waygraph pilot: stub.after ctx.todos n=6 index=4
22
+ [11:37:44.906] waygraph pilot: stub.after ring .shopping_cart_badge tone=success label="Cart badge"
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "waygraph",
3
- "version": "0.15.39",
3
+ "version": "0.15.41",
4
4
  "type": "module",
5
5
  "description": "A typed graph of reusable Blocks for driving a browser through E2E flows.",
6
6
  "license": "MIT",
@@ -19,7 +19,14 @@ against a live site, use `--skill-pilot-blind`.
19
19
  (no goto) even when the app changes URL as a side effect of a button click.
20
20
  3. **Selectors in `*Sel`, never inline.** DOM strings live in a `*Sel` next to the page
21
21
  (static + `(id) => …`). Mem keys store values, not selectors. `waygraph check` warns on
22
- inline selector literals in `Trait.visible` / `Trait.text`.
22
+ inline selector literals in `Trait.visible` / `Trait.text`, and separately on any inline
23
+ selector literal (`.locator("#x")`, `click: "#x"`) in a directory that already has a `*Sel`.
24
+ **Plain CSS first, always.** An id, a class, or `[data-testid=…]` is the selector for
25
+ almost everything real apps have - reach for that before anything else. Don't invent a
26
+ regex/XPath match (`:has-text(/…/)`, `:text-matches(…)`, an XPath locator) for something a
27
+ one-line class or id selector already targets uniquely; `waygraph check` flags this too.
28
+ A regex/text match is for a genuinely unstable DOM with no stable attribute at all, not
29
+ the default first move.
23
30
  4. **Assert-only = `defineAssertBlock`.** Self-loop, no hand-written `act`/`resolve`. Pass
24
31
  an explicit type argument when it sits between two specifically-typed Blocks in a Flow.
25
32
  Not just static content or an action's result - it also names a *feature state* worth a