waygraph 0.12.23 → 0.12.24

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,
@@ -1246,8 +1247,11 @@ async function cycleHighlightRings(page, highlights, gatesFast, opts) {
1246
1247
  size: h.size,
1247
1248
  weight: h.weight,
1248
1249
  });
1249
- if (h.todos && h.todos.length) {
1250
- const rows = normalizeTodos(h.todos, h.todoIndex);
1250
+ if ((opts && opts.todos && opts.todos.length) || (h.todos && h.todos.length)) {
1251
+ const rows =
1252
+ opts && opts.todos && opts.todos.length
1253
+ ? opts.todos
1254
+ : normalizeTodos(h.todos, h.todoIndex);
1251
1255
  await page
1252
1256
  .evaluate((todos) => {
1253
1257
  const ul = document.getElementById("wg-todos");
@@ -2525,9 +2529,11 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2525
2529
  !!r.wasFastForward;
2526
2530
  const fixtures = r.highlightFixtures;
2527
2531
  const flowStyle = r.highlightStyle;
2528
- stubBeforeRef.current = resolveHighlightSlots(r.block, "stubBefore", { fixtures }).map((h) =>
2532
+ const stubBeforePhase = await runStubPhase(r.block, "stubBefore", { fixtures });
2533
+ stubBeforeRef.current = stubBeforePhase.highlights.map((h) =>
2529
2534
  applyHighlightStyleDefaults(h, flowStyle),
2530
2535
  );
2536
+ const stubBeforeTodos = stubBeforePhase.todos || [];
2531
2537
  const isNavBlock = r.block.__waygraphKind === "nav";
2532
2538
  const autoNow = await currentAutoplay();
2533
2539
  // Auto-next: tuck stepper on NavBlocks so the page transition fills the frame.
@@ -2573,12 +2579,7 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2573
2579
  episodeTitle: r.episodeTitle,
2574
2580
  stepperMode,
2575
2581
  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
- })(),
2582
+ todos: stubBeforeTodos,
2582
2583
  ...paceSpeak,
2583
2584
  });
2584
2585
  const edits = await gate();
@@ -2638,9 +2639,11 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2638
2639
  }
2639
2640
  })
2640
2641
  .catch(() => {});
2641
- const errHighlights = resolveHighlightSlots(r.block, "stubOnError", {
2642
+ const errPhase = await runStubPhase(r.block, "stubOnError", {
2642
2643
  fixtures: fixturesOnError,
2643
- }).map((h) => {
2644
+ error: err,
2645
+ });
2646
+ const errHighlights = errPhase.highlights.map((h) => {
2644
2647
  const styled = applyHighlightStyleDefaults(h, r.highlightStyle);
2645
2648
  return {
2646
2649
  selector: styled.selector,
@@ -2650,14 +2653,13 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2650
2653
  tone: styled.tone,
2651
2654
  size: styled.size,
2652
2655
  weight: styled.weight,
2653
- todos: styled.todos,
2654
- todoIndex: styled.todoIndex,
2655
2656
  zoom: styled.zoom,
2656
2657
  };
2657
2658
  });
2658
2659
  await cycleHighlightRings(page, errHighlights, !!pacing.gatesFast, {
2659
2660
  defaultHoldMs: 2000,
2660
2661
  pace: pacing.demoPace,
2662
+ todos: errPhase.todos,
2661
2663
  });
2662
2664
  }
2663
2665
  // A thrown act()/observe()/a failed verify Trait used to just crash
@@ -2723,24 +2725,26 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2723
2725
  });
2724
2726
  }
2725
2727
  let highlights;
2728
+ let stubAfterTodos = [];
2726
2729
  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
- );
2730
+ const afterPhase = await runStubPhase(r.block, "stubAfter", {
2731
+ out: result,
2732
+ fixtures: fixturesAfter,
2733
+ });
2734
+ stubAfterTodos = afterPhase.todos || [];
2735
+ highlights = afterPhase.highlights.map((h) => {
2736
+ const styled = applyHighlightStyleDefaults(h, r.highlightStyle);
2737
+ return {
2738
+ selector: styled.selector,
2739
+ label: formatHighlightCaption(styled),
2740
+ duration: styled.duration,
2741
+ fastMode: styled.fastMode,
2742
+ tone: styled.tone,
2743
+ size: styled.size,
2744
+ weight: styled.weight,
2745
+ zoom: styled.zoom,
2746
+ };
2747
+ });
2744
2748
  } else {
2745
2749
  highlights = extractVerifyHighlights(r.block, result.__state).map((h) =>
2746
2750
  applyHighlightStyleDefaults(h, r.highlightStyle),
@@ -2765,12 +2769,7 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2765
2769
  episodeNumber: r.episodeNumber,
2766
2770
  episodeTitle: r.episodeTitle,
2767
2771
  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
- })(),
2772
+ todos: stubAfterTodos,
2774
2773
  // After a nav under auto-next, stay compact until the next action block expands.
2775
2774
  forceCollapsed: !!process.env.WAYGRAPH_VIDEO || ((await currentAutoplay()) && isNavBlock),
2776
2775
  });
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
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "waygraph",
3
- "version": "0.12.23",
3
+ "version": "0.12.24",
4
4
  "type": "module",
5
5
  "description": "A typed graph of reusable Blocks for driving a browser through E2E flows.",
6
6
  "license": "MIT",