waygraph 0.12.23 → 0.12.25

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
@@ -207,6 +207,7 @@ import { tmpdir } from "node:os";
207
207
  import {
208
208
  resolveHighlightSlots,
209
209
  resolveSlides,
210
+ runStubPhase,
210
211
  resolveFixtureDwellMs,
211
212
  resolveSlideDwellMs,
212
213
  formatHighlightCaption,
@@ -1138,18 +1139,40 @@ async function ensureSelectorInView(page, selector) {
1138
1139
  const loc = page.locator(selector).first();
1139
1140
  await loc.scrollIntoViewIfNeeded().catch(() => {});
1140
1141
  await page
1141
- .evaluate((sel) => {
1142
+ .evaluate(async (sel) => {
1142
1143
  const el = document.querySelector(sel);
1143
1144
  if (!el) return;
1145
+ const waitScroll = (node, ms) =>
1146
+ new Promise((resolve) => {
1147
+ let done = false;
1148
+ const finish = () => {
1149
+ if (done) return;
1150
+ done = true;
1151
+ node.removeEventListener("scrollend", finish);
1152
+ clearTimeout(t);
1153
+ resolve();
1154
+ };
1155
+ const t = setTimeout(finish, ms);
1156
+ try {
1157
+ node.addEventListener("scrollend", finish, { once: true });
1158
+ } catch {
1159
+ /* scrollend unsupported - timeout only */
1160
+ }
1161
+ });
1144
1162
  try {
1145
- el.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
1163
+ el.scrollIntoView({ block: "center", inline: "center", behavior: "smooth" });
1146
1164
  } catch {
1147
1165
  try {
1148
- el.scrollIntoView(true);
1166
+ el.scrollIntoView({ block: "center", inline: "center" });
1149
1167
  } catch {
1150
- /* ignore */
1168
+ try {
1169
+ el.scrollIntoView(true);
1170
+ } catch {
1171
+ /* ignore */
1172
+ }
1151
1173
  }
1152
1174
  }
1175
+ await waitScroll(document.scrollingElement || document.documentElement, 500);
1153
1176
  let p = el.parentElement;
1154
1177
  while (p && p !== document.documentElement && p !== document.body) {
1155
1178
  const st = getComputedStyle(p);
@@ -1159,8 +1182,15 @@ async function ensureSelectorInView(page, selector) {
1159
1182
  const er = el.getBoundingClientRect();
1160
1183
  const pr = p.getBoundingClientRect();
1161
1184
  if (er.left < pr.left || er.right > pr.right || er.top < pr.top || er.bottom > pr.bottom) {
1162
- p.scrollLeft += er.left + er.width / 2 - (pr.left + pr.width / 2);
1163
- p.scrollTop += er.top + er.height / 2 - (pr.top + pr.height / 2);
1185
+ const left = p.scrollLeft + (er.left + er.width / 2 - (pr.left + pr.width / 2));
1186
+ const top = p.scrollTop + (er.top + er.height / 2 - (pr.top + pr.height / 2));
1187
+ try {
1188
+ p.scrollTo({ left, top, behavior: "smooth" });
1189
+ } catch {
1190
+ p.scrollLeft = left;
1191
+ p.scrollTop = top;
1192
+ }
1193
+ await waitScroll(p, 500);
1164
1194
  }
1165
1195
  }
1166
1196
  p = p.parentElement;
@@ -1246,8 +1276,11 @@ async function cycleHighlightRings(page, highlights, gatesFast, opts) {
1246
1276
  size: h.size,
1247
1277
  weight: h.weight,
1248
1278
  });
1249
- if (h.todos && h.todos.length) {
1250
- const rows = normalizeTodos(h.todos, h.todoIndex);
1279
+ if ((opts && opts.todos && opts.todos.length) || (h.todos && h.todos.length)) {
1280
+ const rows =
1281
+ opts && opts.todos && opts.todos.length
1282
+ ? opts.todos
1283
+ : normalizeTodos(h.todos, h.todoIndex);
1251
1284
  await page
1252
1285
  .evaluate((todos) => {
1253
1286
  const ul = document.getElementById("wg-todos");
@@ -2525,9 +2558,11 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2525
2558
  !!r.wasFastForward;
2526
2559
  const fixtures = r.highlightFixtures;
2527
2560
  const flowStyle = r.highlightStyle;
2528
- stubBeforeRef.current = resolveHighlightSlots(r.block, "stubBefore", { fixtures }).map((h) =>
2561
+ const stubBeforePhase = await runStubPhase(r.block, "stubBefore", { fixtures });
2562
+ stubBeforeRef.current = stubBeforePhase.highlights.map((h) =>
2529
2563
  applyHighlightStyleDefaults(h, flowStyle),
2530
2564
  );
2565
+ const stubBeforeTodos = stubBeforePhase.todos || [];
2531
2566
  const isNavBlock = r.block.__waygraphKind === "nav";
2532
2567
  const autoNow = await currentAutoplay();
2533
2568
  // Auto-next: tuck stepper on NavBlocks so the page transition fills the frame.
@@ -2573,12 +2608,7 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2573
2608
  episodeTitle: r.episodeTitle,
2574
2609
  stepperMode,
2575
2610
  forceCollapsed,
2576
- todos: (() => {
2577
- for (const h of stubBeforeRef.current || []) {
2578
- if (h.todos && h.todos.length) return normalizeTodos(h.todos, h.todoIndex);
2579
- }
2580
- return [];
2581
- })(),
2611
+ todos: stubBeforeTodos,
2582
2612
  ...paceSpeak,
2583
2613
  });
2584
2614
  const edits = await gate();
@@ -2638,9 +2668,11 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2638
2668
  }
2639
2669
  })
2640
2670
  .catch(() => {});
2641
- const errHighlights = resolveHighlightSlots(r.block, "stubOnError", {
2671
+ const errPhase = await runStubPhase(r.block, "stubOnError", {
2642
2672
  fixtures: fixturesOnError,
2643
- }).map((h) => {
2673
+ error: err,
2674
+ });
2675
+ const errHighlights = errPhase.highlights.map((h) => {
2644
2676
  const styled = applyHighlightStyleDefaults(h, r.highlightStyle);
2645
2677
  return {
2646
2678
  selector: styled.selector,
@@ -2650,14 +2682,13 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2650
2682
  tone: styled.tone,
2651
2683
  size: styled.size,
2652
2684
  weight: styled.weight,
2653
- todos: styled.todos,
2654
- todoIndex: styled.todoIndex,
2655
2685
  zoom: styled.zoom,
2656
2686
  };
2657
2687
  });
2658
2688
  await cycleHighlightRings(page, errHighlights, !!pacing.gatesFast, {
2659
2689
  defaultHoldMs: 2000,
2660
2690
  pace: pacing.demoPace,
2691
+ todos: errPhase.todos,
2661
2692
  });
2662
2693
  }
2663
2694
  // A thrown act()/observe()/a failed verify Trait used to just crash
@@ -2723,24 +2754,26 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2723
2754
  });
2724
2755
  }
2725
2756
  let highlights;
2757
+ let stubAfterTodos = [];
2726
2758
  if (hasAuthoredStubAfter(r.block, result, fixturesAfter)) {
2727
- highlights = resolveHighlightSlots(r.block, "stubAfter", { out: result, fixtures: fixturesAfter }).map(
2728
- (h) => {
2729
- const styled = applyHighlightStyleDefaults(h, r.highlightStyle);
2730
- return {
2731
- selector: styled.selector,
2732
- label: formatHighlightCaption(styled),
2733
- duration: styled.duration,
2734
- fastMode: styled.fastMode,
2735
- tone: styled.tone,
2736
- size: styled.size,
2737
- weight: styled.weight,
2738
- todos: styled.todos,
2739
- todoIndex: styled.todoIndex,
2740
- zoom: styled.zoom,
2741
- };
2742
- },
2743
- );
2759
+ const afterPhase = await runStubPhase(r.block, "stubAfter", {
2760
+ out: result,
2761
+ fixtures: fixturesAfter,
2762
+ });
2763
+ stubAfterTodos = afterPhase.todos || [];
2764
+ highlights = afterPhase.highlights.map((h) => {
2765
+ const styled = applyHighlightStyleDefaults(h, r.highlightStyle);
2766
+ return {
2767
+ selector: styled.selector,
2768
+ label: formatHighlightCaption(styled),
2769
+ duration: styled.duration,
2770
+ fastMode: styled.fastMode,
2771
+ tone: styled.tone,
2772
+ size: styled.size,
2773
+ weight: styled.weight,
2774
+ zoom: styled.zoom,
2775
+ };
2776
+ });
2744
2777
  } else {
2745
2778
  highlights = extractVerifyHighlights(r.block, result.__state).map((h) =>
2746
2779
  applyHighlightStyleDefaults(h, r.highlightStyle),
@@ -2765,12 +2798,7 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2765
2798
  episodeNumber: r.episodeNumber,
2766
2799
  episodeTitle: r.episodeTitle,
2767
2800
  stepperMode,
2768
- todos: (() => {
2769
- for (const h of highlights || []) {
2770
- if (h.todos && h.todos.length) return normalizeTodos(h.todos, h.todoIndex);
2771
- }
2772
- return [];
2773
- })(),
2801
+ todos: stubAfterTodos,
2774
2802
  // After a nav under auto-next, stay compact until the next action block expands.
2775
2803
  forceCollapsed: !!process.env.WAYGRAPH_VIDEO || ((await currentAutoplay()) && isNavBlock),
2776
2804
  });
package/dist/engine.d.ts CHANGED
@@ -603,9 +603,9 @@ export type NavBlockOptions<Out extends Checkpoint<string>> = {
603
603
  checkpoint: Out["__state"];
604
604
  requires?: readonly MemKey<any>[];
605
605
  verify?: Trait[] | ((out: Out) => Trait[]);
606
- stubBefore?: import("./highlights.js").HighlightStubPhase | ((out: Out) => import("./highlights.js").HighlightStubPhase);
607
- stubAfter?: import("./highlights.js").HighlightStubPhase | ((out: Out) => import("./highlights.js").HighlightStubPhase);
608
- stubOnError?: import("./highlights.js").HighlightStubPhase | ((out: Out) => import("./highlights.js").HighlightStubPhase);
606
+ stubBefore?: import("./highlights.js").HighlightStubPhaseOrFn<Out>;
607
+ stubAfter?: import("./highlights.js").HighlightStubPhaseOrFn<Out>;
608
+ stubOnError?: import("./highlights.js").HighlightStubPhaseOrFn<Out>;
609
609
  /** @deprecated Prefer stubAfter. */
610
610
  highlights?: readonly WaygraphHighlight[] | ((out: Out) => readonly WaygraphHighlight[]);
611
611
  /**
@@ -667,9 +667,9 @@ export type NavClickBlockOptions<Out extends Checkpoint<string>> = {
667
667
  click: string | ((mem: MemPage) => string);
668
668
  requires?: readonly MemKey<any>[];
669
669
  verify?: Trait[] | ((out: Out) => Trait[]);
670
- stubBefore?: import("./highlights.js").HighlightStubPhase | ((out: Out) => import("./highlights.js").HighlightStubPhase);
671
- stubAfter?: import("./highlights.js").HighlightStubPhase | ((out: Out) => import("./highlights.js").HighlightStubPhase);
672
- stubOnError?: import("./highlights.js").HighlightStubPhase | ((out: Out) => import("./highlights.js").HighlightStubPhase);
670
+ stubBefore?: import("./highlights.js").HighlightStubPhaseOrFn<Out>;
671
+ stubAfter?: import("./highlights.js").HighlightStubPhaseOrFn<Out>;
672
+ stubOnError?: import("./highlights.js").HighlightStubPhaseOrFn<Out>;
673
673
  /** @deprecated Prefer stubAfter. */
674
674
  highlights?: readonly WaygraphHighlight[] | ((out: Out) => readonly WaygraphHighlight[]);
675
675
  instanceOptions?: Block<Checkpoint<string>, Out>["instanceOptions"];
@@ -699,9 +699,9 @@ export type PageBlockOptions<Out extends Checkpoint<string>> = {
699
699
  /** Checkpoint tag for this screen (hub). */
700
700
  checkpoint: Out["__state"];
701
701
  verify?: Trait[] | ((out: Out) => Trait[]);
702
- stubBefore?: import("./highlights.js").HighlightStubPhase | ((out: Out) => import("./highlights.js").HighlightStubPhase);
703
- stubAfter?: import("./highlights.js").HighlightStubPhase | ((out: Out) => import("./highlights.js").HighlightStubPhase);
704
- stubOnError?: import("./highlights.js").HighlightStubPhase | ((out: Out) => import("./highlights.js").HighlightStubPhase);
702
+ stubBefore?: import("./highlights.js").HighlightStubPhaseOrFn<Out>;
703
+ stubAfter?: import("./highlights.js").HighlightStubPhaseOrFn<Out>;
704
+ stubOnError?: import("./highlights.js").HighlightStubPhaseOrFn<Out>;
705
705
  /** @deprecated Prefer stubAfter. */
706
706
  highlights?: readonly WaygraphHighlight[] | ((out: Out) => readonly WaygraphHighlight[]);
707
707
  requires?: readonly MemKey<any>[];
@@ -2,6 +2,10 @@
2
2
  * Block stubBefore / stubAfter / stubOnError + flow highlightFixtures + demo slides.
3
3
  * Demo narration only - never affects runGraph pass/fail.
4
4
  *
5
+ * Stubs are **open block lifecycles** - prefer `stubBefore(ctx) { ... }`, not a
6
+ * closed slot object. Inside the fn authors set fixtures (todos, zoom) and rings.
7
+ * Object maps remain a shorthand for highlight-only slots.
8
+ *
5
9
  * Lifecycle (stubs) ≠ yap slides: stubs are fill/after/error rings tied to the block;
6
10
  * slides are multi-step captions with Next between them (long-process explain).
7
11
  *
@@ -26,7 +30,7 @@ export type FixtureDurationFields = {
26
30
  };
27
31
  /**
28
32
  * One checklist row for episode planning (demo panel).
29
- * Prefer {@link WaygraphHighlightStub.todoIndex} for sequential done/current.
33
+ * Set via {@link StubCtx.todos} / {@link StubCtx.todoIndex} inside the lifecycle.
30
34
  */
31
35
  export type WaygraphTodoItem = {
32
36
  text: string;
@@ -36,6 +40,14 @@ export type WaygraphTodoItem = {
36
40
  current?: boolean;
37
41
  };
38
42
  export type WaygraphTodoInput = string | WaygraphTodoItem;
43
+ /** Episode-level fixtures authored inside stubBefore/After/OnError(ctx). */
44
+ export type StubPhaseFixtures = {
45
+ todos?: readonly WaygraphTodoInput[];
46
+ /** Which todo is current (0-based). Rows before = done, after = pending. */
47
+ todoIndex?: number;
48
+ /** Default zoom for rings that omit their own zoom. */
49
+ zoom?: number;
50
+ };
39
51
  /** One named highlight slot on a block (selector + caption). */
40
52
  export interface WaygraphHighlightStub extends FixtureDurationFields {
41
53
  selector: string;
@@ -63,26 +75,71 @@ export interface WaygraphHighlightStub extends FixtureDurationFields {
63
75
  */
64
76
  weight?: HighlightWeight;
65
77
  /**
66
- * Simple episode checklist (demo panel). Same lifecycle as this stub
67
- * (stubBefore / stubAfter / stubOnError). Strings or `{ text, done?, current? }`.
68
- */
69
- todos?: readonly WaygraphTodoInput[];
70
- /**
71
- * Which todo is current (0-based). Rows before = done, after = pending.
72
- * Authored `done`/`current` on an item still win when set.
73
- * Bump in a stub phase function for sequential plans.
74
- */
75
- todoIndex?: number;
76
- /**
77
- * Magnify the target element while this ring is shown (e.g. `1.35`).
78
+ * Magnify this target while its ring is shown (e.g. `1.35`).
78
79
  * Cleared when the ring hides / next highlight starts. `1` or omit = off.
80
+ * Episode default: {@link StubCtx.zoom}.
79
81
  */
80
82
  zoom?: number;
81
83
  }
82
84
  /** Flow fixture patch: label required; selector optional (inherits from stub). */
83
- export type WaygraphHighlightFixture = Partial<Pick<WaygraphHighlightStub, "selector">> & Required<Pick<WaygraphHighlightStub, "label">> & Pick<WaygraphHighlightStub, "detail" | "tag" | "tone" | "size" | "weight" | "todos" | "todoIndex" | "zoom"> & FixtureDurationFields;
85
+ export type WaygraphHighlightFixture = Partial<Pick<WaygraphHighlightStub, "selector">> & Required<Pick<WaygraphHighlightStub, "label">> & Pick<WaygraphHighlightStub, "detail" | "tag" | "tone" | "size" | "weight" | "zoom"> & FixtureDurationFields;
84
86
  export type HighlightStubPhase = Record<string, WaygraphHighlightStub>;
85
- export type HighlightStubPhaseOrFn<Out extends Checkpoint<string>> = HighlightStubPhase | ((out: Out) => HighlightStubPhase);
87
+ /**
88
+ * Open stub lifecycle context. Authors set rings + episode fixtures freely —
89
+ * not restricted to returning a closed slot object.
90
+ *
91
+ * @example
92
+ * stubBefore: (ctx) => {
93
+ * ctx.todos(["Enter email", "Enter password", "Click Sign in"]);
94
+ * ctx.todoIndex(0);
95
+ * ctx.zoom(1.35);
96
+ * ctx.highlights({
97
+ * email: { selector: "#email", label: "Email" },
98
+ * submit: { selector: "#login-button", label: "Sign in" },
99
+ * });
100
+ * }
101
+ * // Sequential plan: bump todoIndex in a later phase / later block's stubBefore
102
+ * stubAfter: (ctx) => {
103
+ * ctx.todos(["Enter email", "Enter password", "Click Sign in"]);
104
+ * ctx.todoIndex(2);
105
+ * ctx.ring("inventory", { selector: ".inventory_list", label: "Landed" });
106
+ * }
107
+ */
108
+ export type StubCtx<Out extends Checkpoint<string> = Checkpoint<string>> = {
109
+ /** Present after resolve (stubAfter). Undefined for stubBefore. */
110
+ readonly out: Out | undefined;
111
+ /** Present on stubOnError when the step threw. */
112
+ readonly error: unknown | undefined;
113
+ /** Replace named highlight rings for this phase. */
114
+ highlights(slots: HighlightStubPhase): void;
115
+ /** Set / overwrite one named ring. */
116
+ ring(id: string, stub: WaygraphHighlightStub): void;
117
+ /** Episode checklist (demo panel). */
118
+ todos(items: readonly WaygraphTodoInput[]): void;
119
+ /** Current checklist index (bump for sequential plans). */
120
+ todoIndex(n: number): void;
121
+ /** Default zoom for rings without their own zoom. */
122
+ zoom(n: number): void;
123
+ /** Batch-set rings + episode fixtures. */
124
+ set(partial: {
125
+ highlights?: HighlightStubPhase;
126
+ } & StubPhaseFixtures): void;
127
+ };
128
+ export type StubLifecycleFn<Out extends Checkpoint<string>> = (ctx: StubCtx<Out>) => void | HighlightStubPhase | Promise<void | HighlightStubPhase>;
129
+ /**
130
+ * Block stub authoring:
131
+ * - **preferred:** open lifecycle `stubBefore(ctx) { ctx.todos(...); ctx.ring(...) }`
132
+ * - **shorthand:** static slot map `{ email: { selector, label } }`
133
+ * - **legacy:** `(out) => slotMap` still accepted (treated as highlights only)
134
+ */
135
+ export type HighlightStubPhaseOrFn<Out extends Checkpoint<string>> = HighlightStubPhase | StubLifecycleFn<Out> | ((out: Out) => HighlightStubPhase);
136
+ /** Result of running a stub phase (rings + episode fixtures). */
137
+ export type StubPhaseResult = {
138
+ highlights: ResolvedHighlight[];
139
+ todos: WaygraphTodoItem[];
140
+ todoIndex?: number;
141
+ zoom?: number;
142
+ };
86
143
  /**
87
144
  * One yap/slide in a multi-step demo caption sequence.
88
145
  * Not block lifecycle - demo pauses (Next / auto-next) between slides.
@@ -223,9 +280,21 @@ export type ResolvedHighlight = WaygraphHighlightStub;
223
280
  */
224
281
  export declare function normalizeTodos(todos: readonly WaygraphTodoInput[] | undefined, todoIndex?: number): WaygraphTodoItem[];
225
282
  /**
226
- * Resolve ordered highlight slots for a phase, merging flow fixtures over block stubs.
227
- * Shim: empty stubAfter + legacy `instruction.highlights` -> auto-keyed stubAfter.
228
- * `stubOnError` is fail-path only (demo step catch) - never run on success.
283
+ * Run an open stub lifecycle (preferred) or resolve a static slot map.
284
+ * Returns rings + episode fixtures (todos / todoIndex / zoom).
285
+ *
286
+ * @example
287
+ * const before = await runStubPhase(block, "stubBefore", { fixtures });
288
+ * // before.todos, before.highlights
289
+ */
290
+ export declare function runStubPhase(block: Block<any, any>, phase: HighlightStubPhaseName, opts?: {
291
+ out?: Checkpoint<string>;
292
+ error?: unknown;
293
+ fixtures?: HighlightFixtureMap;
294
+ }): Promise<StubPhaseResult>;
295
+ /**
296
+ * Sync resolve of highlight slots. Prefer {@link runStubPhase} for open
297
+ * async lifecycles + episode fixtures (todos/zoom).
229
298
  */
230
299
  export declare function resolveHighlightSlots(block: Block<any, any>, phase: HighlightStubPhaseName, opts?: {
231
300
  out?: Checkpoint<string>;
@@ -248,12 +317,12 @@ export declare function formatHighlightCaption(h: {
248
317
  tone?: HighlightTone | string;
249
318
  }): string;
250
319
  /**
251
- * True when the block authored a non-empty stubAfter (after resolve + shim),
252
- * including legacy highlights. When true, demo should not use verify-trait fallback.
320
+ * True when the block authored stubAfter (lifecycle fn, slots, fixtures, or legacy highlights).
321
+ * When true, demo should not use verify-trait fallback.
253
322
  */
254
323
  export declare function hasAuthoredStubAfter(block: Block<any, any>, out?: Checkpoint<string>, fixtures?: HighlightFixtureMap): boolean;
255
324
  /**
256
- * True when stubOnError resolves to at least one ring (block and/or flow fixture).
325
+ * True when stubOnError is authored (lifecycle fn and/or rings).
257
326
  * Demo fail path only - success never consults this.
258
327
  */
259
328
  export declare function hasAuthoredStubOnError(block: Block<any, any>, fixtures?: HighlightFixtureMap): boolean;
@@ -2,6 +2,10 @@
2
2
  * Block stubBefore / stubAfter / stubOnError + flow highlightFixtures + demo slides.
3
3
  * Demo narration only - never affects runGraph pass/fail.
4
4
  *
5
+ * Stubs are **open block lifecycles** - prefer `stubBefore(ctx) { ... }`, not a
6
+ * closed slot object. Inside the fn authors set fixtures (todos, zoom) and rings.
7
+ * Object maps remain a shorthand for highlight-only slots.
8
+ *
5
9
  * Lifecycle (stubs) ≠ yap slides: stubs are fill/after/error rings tied to the block;
6
10
  * slides are multi-step captions with Next between them (long-process explain).
7
11
  *
@@ -314,25 +318,18 @@ export function normalizeTodos(todos, todoIndex) {
314
318
  return item;
315
319
  });
316
320
  }
317
- function pickTodoFields(base, patch) {
318
- const out = {};
319
- const todos = patch?.todos !== undefined ? patch.todos : base?.todos;
320
- const todoIndex = patch?.todoIndex !== undefined ? patch.todoIndex : base?.todoIndex;
321
+ function pickZoom(base, patch) {
321
322
  const zoom = patch?.zoom !== undefined ? patch.zoom : base?.zoom;
322
- if (todos !== undefined)
323
- out.todos = todos;
324
- if (todoIndex !== undefined)
325
- out.todoIndex = todoIndex;
326
323
  if (zoom !== undefined && Number.isFinite(zoom) && zoom > 0)
327
- out.zoom = zoom;
328
- return out;
324
+ return { zoom };
325
+ return {};
329
326
  }
330
327
  function mergeSlot(base, patch) {
331
328
  if (!base && !patch)
332
329
  return null;
333
330
  const dwell = pickDurationFields(base, patch);
334
331
  const style = pickStyleFields(base, patch);
335
- const todos = pickTodoFields(base, patch);
332
+ const zoom = pickZoom(base, patch);
336
333
  if (!base && patch) {
337
334
  if (!patch.selector)
338
335
  return null;
@@ -343,7 +340,7 @@ function mergeSlot(base, patch) {
343
340
  ...(patch.tag ? { tag: patch.tag } : {}),
344
341
  ...style,
345
342
  ...dwell,
346
- ...todos,
343
+ ...zoom,
347
344
  };
348
345
  }
349
346
  if (base && !patch) {
@@ -352,7 +349,7 @@ function mergeSlot(base, patch) {
352
349
  ...(base.tone ? { tone: normalizeHighlightTone(base.tone) } : {}),
353
350
  ...(base.size ? { size: normalizeHighlightSize(base.size) } : {}),
354
351
  ...(base.weight ? { weight: normalizeHighlightWeight(base.weight) } : {}),
355
- ...pickTodoFields(base, undefined),
352
+ ...pickZoom(base, undefined),
356
353
  };
357
354
  }
358
355
  return {
@@ -366,9 +363,78 @@ function mergeSlot(base, patch) {
366
363
  ...(patch.tag !== undefined ? { tag: patch.tag } : base.tag ? { tag: base.tag } : {}),
367
364
  ...style,
368
365
  ...dwell,
369
- ...todos,
366
+ ...zoom,
370
367
  };
371
368
  }
369
+ function createStubCtx(bag, opts) {
370
+ return {
371
+ out: opts.out,
372
+ error: opts.error,
373
+ highlights(slots) {
374
+ bag.highlights = { ...(slots || {}) };
375
+ },
376
+ ring(id, stub) {
377
+ bag.highlights = { ...bag.highlights, [id]: stub };
378
+ },
379
+ todos(items) {
380
+ bag.todos = items;
381
+ },
382
+ todoIndex(n) {
383
+ bag.todoIndex = n;
384
+ },
385
+ zoom(n) {
386
+ if (Number.isFinite(n) && n > 0)
387
+ bag.zoom = n;
388
+ },
389
+ set(partial) {
390
+ if (partial.highlights)
391
+ bag.highlights = { ...partial.highlights };
392
+ if (partial.todos !== undefined)
393
+ bag.todos = partial.todos;
394
+ if (partial.todoIndex !== undefined)
395
+ bag.todoIndex = partial.todoIndex;
396
+ if (partial.zoom !== undefined && Number.isFinite(partial.zoom) && partial.zoom > 0) {
397
+ bag.zoom = partial.zoom;
398
+ }
399
+ },
400
+ };
401
+ }
402
+ /** Lift legacy per-slot todos (0.12.23) onto the phase bag once. */
403
+ function liftLegacySlotTodos(bag) {
404
+ if (bag.todos && bag.todos.length)
405
+ return;
406
+ for (const stub of Object.values(bag.highlights)) {
407
+ const legacy = stub;
408
+ if (legacy.todos && legacy.todos.length) {
409
+ bag.todos = legacy.todos;
410
+ if (legacy.todoIndex !== undefined)
411
+ bag.todoIndex = legacy.todoIndex;
412
+ return;
413
+ }
414
+ }
415
+ }
416
+ function applyDefaultZoom(slots, defaultZoom) {
417
+ if (defaultZoom === undefined || !(defaultZoom > 0))
418
+ return slots;
419
+ return slots.map((h) => (h.zoom !== undefined && h.zoom > 0 ? h : { ...h, zoom: defaultZoom }));
420
+ }
421
+ function mergePhaseMaps(phaseMap, fixturePhase) {
422
+ const slotIds = new Set([...Object.keys(phaseMap), ...Object.keys(fixturePhase || {})]);
423
+ const ordered = [...slotIds].sort((a, b) => {
424
+ const na = Number(a);
425
+ const nb = Number(b);
426
+ if (!Number.isNaN(na) && !Number.isNaN(nb))
427
+ return na - nb;
428
+ return a.localeCompare(b);
429
+ });
430
+ const resolved = [];
431
+ for (const id of ordered) {
432
+ const merged = mergeSlot(phaseMap[id], fixturePhase?.[id]);
433
+ if (merged)
434
+ resolved.push(merged);
435
+ }
436
+ return resolved;
437
+ }
372
438
  function phaseFromLegacyHighlights(highlights) {
373
439
  const out = {};
374
440
  if (!highlights)
@@ -384,58 +450,121 @@ function phaseFromLegacyHighlights(highlights) {
384
450
  });
385
451
  return out;
386
452
  }
453
+ function readStubRaw(block, phase) {
454
+ const instr = block.instruction;
455
+ if (phase === "stubBefore")
456
+ return instr.stubBefore;
457
+ if (phase === "stubAfter")
458
+ return instr.stubAfter;
459
+ return instr.stubOnError;
460
+ }
461
+ function applyLegacyHighlightsShim(block, phase, phaseMap, out) {
462
+ if (phase !== "stubAfter" || Object.keys(phaseMap).length > 0)
463
+ return phaseMap;
464
+ const instr = block.instruction;
465
+ if (!instr.highlights)
466
+ return phaseMap;
467
+ let legacy = instr.highlights;
468
+ if (typeof legacy === "function") {
469
+ try {
470
+ legacy = legacy(out ?? { __state: "" });
471
+ }
472
+ catch {
473
+ legacy = [];
474
+ }
475
+ }
476
+ return phaseFromLegacyHighlights(Array.isArray(legacy) ? legacy : []);
477
+ }
387
478
  /**
388
- * Resolve ordered highlight slots for a phase, merging flow fixtures over block stubs.
389
- * Shim: empty stubAfter + legacy `instruction.highlights` -> auto-keyed stubAfter.
390
- * `stubOnError` is fail-path only (demo step catch) - never run on success.
479
+ * Run an open stub lifecycle (preferred) or resolve a static slot map.
480
+ * Returns rings + episode fixtures (todos / todoIndex / zoom).
481
+ *
482
+ * @example
483
+ * const before = await runStubPhase(block, "stubBefore", { fixtures });
484
+ * // before.todos, before.highlights
391
485
  */
392
- export function resolveHighlightSlots(block, phase, opts) {
393
- const instr = block.instruction;
394
- let raw = phase === "stubBefore"
395
- ? instr.stubBefore
396
- : phase === "stubAfter"
397
- ? instr.stubAfter
398
- : instr.stubOnError;
399
- let phaseMap = {};
486
+ export async function runStubPhase(block, phase, opts) {
487
+ const bag = { highlights: {} };
488
+ const ctx = createStubCtx(bag, {
489
+ ...(opts?.out !== undefined ? { out: opts.out } : {}),
490
+ ...(opts?.error !== undefined ? { error: opts.error } : {}),
491
+ });
492
+ const raw = readStubRaw(block, phase);
400
493
  if (typeof raw === "function") {
401
494
  try {
402
- phaseMap = raw(opts?.out ?? { __state: "" }) || {};
495
+ const ret = await raw(ctx);
496
+ if (isStubPhase(ret)) {
497
+ bag.highlights = { ...bag.highlights, ...ret };
498
+ }
403
499
  }
404
500
  catch {
405
- phaseMap = {};
501
+ /* best-effort */
502
+ }
503
+ // Compat: legacy `(out) => map` that expected Checkpoint (__state on arg)
504
+ if (Object.keys(bag.highlights).length === 0 && !bag.todos) {
505
+ try {
506
+ const legacyOut = (opts?.out ?? { __state: "" });
507
+ const ret2 = raw(legacyOut);
508
+ if (isStubPhase(ret2))
509
+ bag.highlights = { ...ret2 };
510
+ }
511
+ catch {
512
+ /* ignore */
513
+ }
406
514
  }
407
515
  }
408
516
  else if (isStubPhase(raw)) {
409
- phaseMap = { ...raw };
517
+ bag.highlights = { ...raw };
410
518
  }
411
- if (phase === "stubAfter" && Object.keys(phaseMap).length === 0 && instr.highlights) {
412
- let legacy = instr.highlights;
413
- if (typeof legacy === "function") {
519
+ bag.highlights = applyLegacyHighlightsShim(block, phase, bag.highlights, opts?.out);
520
+ liftLegacySlotTodos(bag);
521
+ const fixturePhase = opts?.fixtures?.[block.name]?.[phase];
522
+ let highlights = mergePhaseMaps(bag.highlights, fixturePhase);
523
+ highlights = applyDefaultZoom(highlights, bag.zoom);
524
+ const todos = normalizeTodos(bag.todos, bag.todoIndex);
525
+ return {
526
+ highlights,
527
+ todos,
528
+ ...(bag.todoIndex !== undefined ? { todoIndex: bag.todoIndex } : {}),
529
+ ...(bag.zoom !== undefined ? { zoom: bag.zoom } : {}),
530
+ };
531
+ }
532
+ /**
533
+ * Sync resolve of highlight slots. Prefer {@link runStubPhase} for open
534
+ * async lifecycles + episode fixtures (todos/zoom).
535
+ */
536
+ export function resolveHighlightSlots(block, phase, opts) {
537
+ const bag = { highlights: {} };
538
+ const raw = readStubRaw(block, phase);
539
+ if (typeof raw === "function") {
540
+ try {
541
+ const ctx = createStubCtx(bag, {
542
+ ...(opts?.out !== undefined ? { out: opts.out } : {}),
543
+ });
544
+ const ret = raw(ctx);
545
+ if (isStubPhase(ret)) {
546
+ bag.highlights = { ...bag.highlights, ...ret };
547
+ }
548
+ }
549
+ catch {
414
550
  try {
415
- legacy = legacy(opts?.out ?? { __state: "" });
551
+ const legacyOut = (opts?.out ?? { __state: "" });
552
+ const ret2 = raw(legacyOut);
553
+ if (isStubPhase(ret2))
554
+ bag.highlights = { ...ret2 };
416
555
  }
417
556
  catch {
418
- legacy = [];
557
+ bag.highlights = {};
419
558
  }
420
559
  }
421
- phaseMap = phaseFromLegacyHighlights(Array.isArray(legacy) ? legacy : []);
422
560
  }
423
- const fixturePhase = opts?.fixtures?.[block.name]?.[phase];
424
- const slotIds = new Set([...Object.keys(phaseMap), ...Object.keys(fixturePhase || {})]);
425
- const ordered = [...slotIds].sort((a, b) => {
426
- const na = Number(a);
427
- const nb = Number(b);
428
- if (!Number.isNaN(na) && !Number.isNaN(nb))
429
- return na - nb;
430
- return a.localeCompare(b);
431
- });
432
- const resolved = [];
433
- for (const id of ordered) {
434
- const merged = mergeSlot(phaseMap[id], fixturePhase?.[id]);
435
- if (merged)
436
- resolved.push(merged);
561
+ else if (isStubPhase(raw)) {
562
+ bag.highlights = { ...raw };
437
563
  }
438
- return resolved;
564
+ bag.highlights = applyLegacyHighlightsShim(block, phase, bag.highlights, opts?.out);
565
+ liftLegacySlotTodos(bag);
566
+ const fixturePhase = opts?.fixtures?.[block.name]?.[phase];
567
+ return applyDefaultZoom(mergePhaseMaps(bag.highlights, fixturePhase), bag.zoom);
439
568
  }
440
569
  /**
441
570
  * Resolve multi-step yap slides: flow fixture `slides` wins when present,
@@ -470,21 +599,39 @@ export function formatHighlightCaption(h) {
470
599
  return `${icon}${tag}${primary} - ${h.detail}`;
471
600
  return `${icon}${tag}${primary}`;
472
601
  }
602
+ function stubPhaseLooksAuthored(block, phase, fixtures) {
603
+ const raw = readStubRaw(block, phase);
604
+ if (typeof raw === "function")
605
+ return true;
606
+ if (isStubPhase(raw) && Object.keys(raw).length > 0)
607
+ return true;
608
+ const fx = fixtures?.[block.name]?.[phase];
609
+ if (fx && Object.keys(fx).length > 0)
610
+ return true;
611
+ return false;
612
+ }
473
613
  /**
474
- * True when the block authored a non-empty stubAfter (after resolve + shim),
475
- * including legacy highlights. When true, demo should not use verify-trait fallback.
614
+ * True when the block authored stubAfter (lifecycle fn, slots, fixtures, or legacy highlights).
615
+ * When true, demo should not use verify-trait fallback.
476
616
  */
477
617
  export function hasAuthoredStubAfter(block, out, fixtures) {
618
+ if (stubPhaseLooksAuthored(block, "stubAfter", fixtures))
619
+ return true;
620
+ const instr = block.instruction;
621
+ if (instr.highlights)
622
+ return true;
478
623
  return resolveHighlightSlots(block, "stubAfter", {
479
624
  ...(out !== undefined ? { out } : {}),
480
625
  ...(fixtures !== undefined ? { fixtures } : {}),
481
626
  }).length > 0;
482
627
  }
483
628
  /**
484
- * True when stubOnError resolves to at least one ring (block and/or flow fixture).
629
+ * True when stubOnError is authored (lifecycle fn and/or rings).
485
630
  * Demo fail path only - success never consults this.
486
631
  */
487
632
  export function hasAuthoredStubOnError(block, fixtures) {
633
+ if (stubPhaseLooksAuthored(block, "stubOnError", fixtures))
634
+ return true;
488
635
  return (resolveHighlightSlots(block, "stubOnError", {
489
636
  ...(fixtures !== undefined ? { fixtures } : {}),
490
637
  }).length > 0);
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, resolveFixtureDwellMs, resolveSlideDwellMs, formatHighlightCaption, hasAuthoredStubAfter, hasAuthoredStubOnError, normalizeHighlightTone, normalizeDemoPace, normalizeHighlightSize, normalizeHighlightWeight, applyHighlightStyleDefaults, toneIconPrefix, resolveStepDemoPace, demoPaceScale, demoPaceIsBlitz, demoPaceIsFast, demoPaceIsSlow, demoPaceGateMs, formatDemoPaceBadge, formatDemoPaceLabel, normalizeTodos, 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, FixtureDurationFields, HighlightStubPhase, HighlightStubPhaseName, HighlightFixtureMap, ResolvedHighlight, WaygraphHighlight, HighlightTone, HighlightSize, HighlightWeight, HighlightStyleDefaults, DemoPace, DemoPaceName, } 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, 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, FixtureDurationFields, HighlightStubPhase, HighlightStubPhaseName, HighlightFixtureMap, ResolvedHighlight, WaygraphHighlight, HighlightTone, HighlightSize, HighlightWeight, HighlightStyleDefaults, DemoPace, DemoPaceName, StubCtx, StubLifecycleFn, StubPhaseResult, StubPhaseFixtures, } 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, resolveFixtureDwellMs, resolveSlideDwellMs, formatHighlightCaption, hasAuthoredStubAfter, hasAuthoredStubOnError, normalizeHighlightTone, normalizeDemoPace, normalizeHighlightSize, normalizeHighlightWeight, applyHighlightStyleDefaults, toneIconPrefix, resolveStepDemoPace, demoPaceScale, demoPaceIsBlitz, demoPaceIsFast, demoPaceIsSlow, demoPaceGateMs, formatDemoPaceBadge, formatDemoPaceLabel, normalizeTodos, 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, 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";
package/dist/types.d.ts CHANGED
@@ -2,7 +2,7 @@ import type { Page } from "@playwright/test";
2
2
  import type { MemPage, MemKey } from "./mem-page.js";
3
3
  import type { Trait } from "./trait.js";
4
4
  import type { HighlightStubPhaseOrFn, WaygraphHighlight, WaygraphSlidesOrFn } from "./highlights.js";
5
- export type { WaygraphHighlight, WaygraphHighlightStub, WaygraphHighlightFixture, WaygraphSlide, HighlightStubPhase, HighlightStubPhaseOrFn, HighlightFixtureMap, ResolvedHighlight, } from "./highlights.js";
5
+ export type { WaygraphHighlight, WaygraphHighlightStub, WaygraphHighlightFixture, WaygraphSlide, HighlightStubPhase, HighlightStubPhaseOrFn, HighlightFixtureMap, ResolvedHighlight, StubCtx, StubLifecycleFn, StubPhaseResult, StubPhaseFixtures, WaygraphTodoItem, WaygraphTodoInput, } from "./highlights.js";
6
6
  /**
7
7
  * A Checkpoint is identified solely by its string tag - a phantom marker with no
8
8
  * embedded data. Two Checkpoints with the same tag are the same state everywhere.
@@ -90,22 +90,34 @@ export interface Instruction<In extends Checkpoint<string>, Out extends Checkpoi
90
90
  */
91
91
  verify?: Trait[] | ((out: Out) => Trait[]);
92
92
  /**
93
- * Catch-all demo slots shown **before / during** `act` (fill/click rings).
94
- * Named map; `{}` is valid. Flow {@link HighlightFixtureMap} overrides labels.
95
- * @example stubBefore: { email: { selector: "#email", label: "Email" } }
93
+ * Open block lifecycle **before / during** `act` (demo rings + fixtures).
94
+ * Prefer `stubBefore(ctx) { ctx.todos(...); ctx.ring(...) }` — not a closed
95
+ * slot object. Object map `{ email: { selector, label } }` remains a shorthand.
96
+ * Flow {@link HighlightFixtureMap} still overrides ring labels.
97
+ * @example stubBefore: (ctx) => {
98
+ * ctx.todos(["Email", "Password", "Submit"]);
99
+ * ctx.todoIndex(0);
100
+ * ctx.highlights({ email: { selector: "#email", label: "Email" } });
101
+ * }
96
102
  */
97
103
  stubBefore?: HighlightStubPhaseOrFn<Out>;
98
104
  /**
99
- * Catch-all demo slots shown **after** resolve (step panel rings).
100
- * Named map; `{}` is valid. Prefer this over legacy {@link Instruction.highlights}.
101
- * @example stubAfter: { badge: { selector: ".cart", label: "Cart updated" } }
105
+ * Open block lifecycle **after** resolve (step panel rings + fixtures).
106
+ * Prefer `stubAfter(ctx) { ... }`. Object map shorthand still works.
107
+ * Prefer this over legacy {@link Instruction.highlights}.
108
+ * @example stubAfter: (ctx) => {
109
+ * ctx.todoIndex(2);
110
+ * ctx.ring("badge", { selector: ".cart", label: "Cart updated" });
111
+ * }
102
112
  */
103
113
  stubAfter?: HighlightStubPhaseOrFn<Out>;
104
114
  /**
105
- * Demo slots shown when a **step throws** (failed verify / act error) -
115
+ * Open block lifecycle when a **step throws** (failed verify / act error) -
106
116
  * before the error panel. Success-only {@link Instruction.stubAfter} never runs
107
- * here; authors keep distinct BUG/FAIL copy. Named map; `{}` is valid.
108
- * @example stubOnError: { card: { selector: ".alt-1", label: "Card at failure" } }
117
+ * here. Prefer `stubOnError(ctx) { ... }`.
118
+ * @example stubOnError: (ctx) => {
119
+ * ctx.ring("card", { selector: ".alt-1", label: "Card at failure", tag: "BUG" });
120
+ * }
109
121
  */
110
122
  stubOnError?: HighlightStubPhaseOrFn<Out>;
111
123
  /**
@@ -17,7 +17,7 @@
17
17
  }
18
18
  },
19
19
  "../..": {
20
- "version": "0.12.4",
20
+ "version": "0.12.24",
21
21
  "license": "MIT",
22
22
  "dependencies": {
23
23
  "tsx": "^4.0.0"
@@ -26,7 +26,7 @@
26
26
  "waygraph": "bin/waygraph"
27
27
  },
28
28
  "devDependencies": {
29
- "@playwright/test": "^1.55.0",
29
+ "@playwright/test": "^1.63.0",
30
30
  "@types/node": "^26.0.0",
31
31
  "typescript": "^5.6.0"
32
32
  },
@@ -45,20 +45,29 @@ export const SubmitLoginActionBlock = defineMethodBlock<LoginPage, LoginSubmitOu
45
45
  out.__state === "LoggedIn"
46
46
  ? [Trait.url({ pathname: "/inventory.html" })]
47
47
  : [Trait.visible('[data-test="error"]')],
48
- stubBefore: {
49
- username: { selector: "#user-name", label: "Username" },
50
- password: { selector: "#password", label: "Password" },
51
- submit: { selector: "#login-button", label: "Login" },
48
+ // Open lifecycle: set fixtures + rings inside the fn (not a closed slot object).
49
+ stubBefore: (ctx) => {
50
+ ctx.todos(["Enter username", "Enter password", "Click Login"]);
51
+ ctx.todoIndex(0);
52
+ ctx.highlights({
53
+ username: { selector: "#user-name", label: "Username" },
54
+ password: { selector: "#password", label: "Password" },
55
+ submit: { selector: "#login-button", label: "Login" },
56
+ });
57
+ },
58
+ stubAfter: (ctx) => {
59
+ // Sequential plan: bump index after a successful submit beat.
60
+ ctx.todos(["Enter username", "Enter password", "Click Login"]);
61
+ ctx.todoIndex(2);
52
62
  },
53
- stubAfter: {},
54
63
  // Fail-path rings (demo step throw only) - locked-out / wrong-password
55
64
  // bug-repro narration; success never shows these.
56
- stubOnError: {
57
- error: {
65
+ stubOnError: (ctx) => {
66
+ ctx.ring("error", {
58
67
  selector: '[data-test="error"]',
59
68
  label: "Login error banner",
60
69
  duration: true,
61
- },
70
+ });
62
71
  },
63
72
  },
64
73
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "waygraph",
3
- "version": "0.12.23",
3
+ "version": "0.12.25",
4
4
  "type": "module",
5
5
  "description": "A typed graph of reusable Blocks for driving a browser through E2E flows.",
6
6
  "license": "MIT",
@@ -45,18 +45,25 @@ export const SubmitLoginActionBlock = defineMethodBlock<LoginPage, LoginSubmitOu
45
45
  out.__state === "LoggedIn"
46
46
  ? [Trait.url({ pathname: "/inventory.html" })]
47
47
  : [Trait.visible('[data-test="error"]')],
48
- stubBefore: {
49
- username: { selector: "#user-name", label: "Username" },
50
- password: { selector: "#password", label: "Password" },
51
- submit: { selector: "#login-button", label: "Login" },
48
+ stubBefore: (ctx) => {
49
+ ctx.todos(["Enter username", "Enter password", "Click Login"]);
50
+ ctx.todoIndex(0);
51
+ ctx.highlights({
52
+ username: { selector: "#user-name", label: "Username" },
53
+ password: { selector: "#password", label: "Password" },
54
+ submit: { selector: "#login-button", label: "Login" },
55
+ });
52
56
  },
53
- stubAfter: {},
54
- stubOnError: {
55
- error: {
57
+ stubAfter: (ctx) => {
58
+ ctx.todos(["Enter username", "Enter password", "Click Login"]);
59
+ ctx.todoIndex(2);
60
+ },
61
+ stubOnError: (ctx) => {
62
+ ctx.ring("error", {
56
63
  selector: '[data-test="error"]',
57
64
  label: "Login error banner",
58
65
  duration: true,
59
- },
66
+ });
60
67
  },
61
68
  },
62
69
  });