waygraph 0.15.25 → 0.15.26

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,1416 +1,10 @@
1
- /**
2
- * Block stubBefore / stubAfter / stubOnError + flow highlightFixtures + demo slides.
3
- * Demo narration only - never affects runGraph pass/fail.
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
- *
9
- * Lifecycle (stubs) ≠ yap slides: stubs are fill/after/error rings tied to the block;
10
- * slides are multi-step captions with Next between them (long-process explain).
11
- *
12
- * All fixture shapes share optional {@link FixtureDurationFields} so demo dwell
13
- * before advancing is authored the same way on stubs, flow fixtures, and slides.
14
- */
15
- function envFlagBool(env, key, fallback) {
16
- const v = env[key];
17
- if (v === undefined || v === "")
18
- return fallback;
19
- const s = String(v).trim().toLowerCase();
20
- if (s === "0" || s === "false" || s === "off" || s === "no")
21
- return false;
22
- if (s === "1" || s === "true" || s === "on" || s === "yes")
23
- return true;
24
- return fallback;
25
- }
26
- function envFlagInt(env, key, fallback) {
27
- const n = Number(env[key]);
28
- return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
29
- }
30
- /**
31
- * Resolve todo-dock UX from env + optional author patch.
32
- * `WAYGRAPH_TODO_UI=full|off|legacy` forces compact/collision/behindRing off.
33
- */
34
- export function resolveTodoDockUi(patch, env = process.env) {
35
- const mode = String(env.WAYGRAPH_TODO_UI || "smart")
36
- .trim()
37
- .toLowerCase();
38
- const full = mode === "full" || mode === "off" || mode === "0" || mode === "legacy" || mode === "classic";
39
- const base = {
40
- compact: full ? false : envFlagBool(env, "WAYGRAPH_TODO_COMPACT", true),
41
- collision: full ? false : envFlagBool(env, "WAYGRAPH_TODO_COLLISION", true),
42
- behindRing: full ? false : envFlagBool(env, "WAYGRAPH_TODO_BEHIND", true),
43
- cap: envFlagInt(env, "WAYGRAPH_TODO_CAP", 5),
44
- expandCap: envFlagInt(env, "WAYGRAPH_TODO_EXPAND_CAP", 14),
45
- };
46
- if (base.expandCap < base.cap)
47
- base.expandCap = base.cap;
48
- if (!patch)
49
- return base;
50
- const cap = patch.cap !== undefined && Number.isFinite(patch.cap)
51
- ? Math.max(1, Math.floor(Number(patch.cap)))
52
- : base.cap;
53
- let expandCap = patch.expandCap !== undefined && Number.isFinite(patch.expandCap)
54
- ? Math.max(1, Math.floor(Number(patch.expandCap)))
55
- : base.expandCap;
56
- if (expandCap < cap)
57
- expandCap = cap;
58
- return {
59
- compact: patch.compact !== undefined ? !!patch.compact : base.compact,
60
- collision: patch.collision !== undefined ? !!patch.collision : base.collision,
61
- behindRing: patch.behindRing !== undefined ? !!patch.behindRing : base.behindRing,
62
- cap,
63
- expandCap,
64
- };
65
- }
66
- /** Built-in preset sizes (CSS pixels) — stored in portrait for mobile/tablet. */
67
- export const DEVICE_PRESETS = {
68
- mobile: {
69
- preset: "mobile",
70
- viewport: { width: 390, height: 844, deviceScaleFactor: 2 },
71
- isMobile: true,
72
- hasTouch: true,
73
- touchMode: true,
74
- orientation: "portrait",
75
- },
76
- tablet: {
77
- preset: "tablet",
78
- viewport: { width: 768, height: 1024, deviceScaleFactor: 2 },
79
- isMobile: true,
80
- hasTouch: true,
81
- touchMode: true,
82
- orientation: "portrait",
83
- },
84
- desktop: {
85
- preset: "desktop",
86
- viewport: { width: 1280, height: 720, deviceScaleFactor: 1 },
87
- isMobile: false,
88
- hasTouch: false,
89
- touchMode: false,
90
- orientation: "landscape",
91
- },
92
- };
93
- export function normalizeDevicePreset(raw) {
94
- const s = String(raw ?? "")
95
- .trim()
96
- .toLowerCase();
97
- if (s === "mobile" || s === "phone" || s === "m")
98
- return "mobile";
99
- if (s === "tablet" || s === "pad" || s === "t")
100
- return "tablet";
101
- if (s === "desktop" || s === "desk" || s === "d" || s === "main")
102
- return "desktop";
103
- return undefined;
104
- }
105
- export function normalizeDeviceOrientation(raw) {
106
- const s = String(raw ?? "")
107
- .trim()
108
- .toLowerCase();
109
- if (s === "portrait" || s === "port" || s === "tall" || s === "p")
110
- return "portrait";
111
- if (s === "landscape" || s === "land" || s === "wide" || s === "l")
112
- return "landscape";
113
- return undefined;
114
- }
115
- /** Infer orientation from box (square counts as landscape). */
116
- export function orientationFromViewport(vp) {
117
- return vp.height > vp.width ? "portrait" : "landscape";
118
- }
119
- /**
120
- * Swap width/height so the viewport matches the requested orientation.
121
- * Preserves deviceScaleFactor. No-op when already oriented.
122
- */
123
- export function applyOrientation(state, orientation) {
124
- const vp = { ...state.viewport };
125
- const current = orientationFromViewport(vp);
126
- if (current !== orientation) {
127
- const w = vp.width;
128
- vp.width = vp.height;
129
- vp.height = w;
130
- }
131
- return { ...state, viewport: vp, orientation };
132
- }
133
- /** Resolve a preset name or partial state into a full DeviceState. */
134
- export function resolveDeviceState(input, touchOverride, orientationOverride) {
135
- if (input === undefined)
136
- return undefined;
137
- let resolved;
138
- if (typeof input === "string") {
139
- const p = normalizeDevicePreset(input);
140
- if (!p)
141
- return undefined;
142
- const base = DEVICE_PRESETS[p];
143
- const touchMode = touchOverride !== undefined ? !!touchOverride : !!base.touchMode;
144
- resolved = {
145
- preset: base.preset,
146
- viewport: { ...base.viewport },
147
- isMobile: base.isMobile,
148
- hasTouch: base.hasTouch || touchMode,
149
- touchMode,
150
- orientation: base.orientation || orientationFromViewport(base.viewport),
151
- };
152
- }
153
- else if ("width" in input && "height" in input && !("preset" in input)) {
154
- const vp = input;
155
- const touchMode = touchOverride !== undefined ? !!touchOverride : true;
156
- const box = {
157
- width: Math.max(200, Math.floor(vp.width)),
158
- height: Math.max(200, Math.floor(vp.height)),
159
- ...(vp.deviceScaleFactor !== undefined
160
- ? { deviceScaleFactor: vp.deviceScaleFactor }
161
- : { deviceScaleFactor: 2 }),
162
- };
163
- resolved = {
164
- preset: "mobile",
165
- viewport: box,
166
- isMobile: true,
167
- hasTouch: true,
168
- touchMode,
169
- orientation: orientationFromViewport(box),
170
- };
171
- }
172
- else {
173
- const d = input;
174
- const preset = normalizeDevicePreset(d.preset) || "desktop";
175
- const base = DEVICE_PRESETS[preset];
176
- const touchMode = touchOverride !== undefined
177
- ? !!touchOverride
178
- : d.touchMode !== undefined
179
- ? !!d.touchMode
180
- : !!base.touchMode;
181
- const viewport = d.viewport
182
- ? {
183
- width: Math.max(200, Math.floor(d.viewport.width)),
184
- height: Math.max(200, Math.floor(d.viewport.height)),
185
- ...(d.viewport.deviceScaleFactor !== undefined
186
- ? { deviceScaleFactor: d.viewport.deviceScaleFactor }
187
- : base.viewport.deviceScaleFactor !== undefined
188
- ? { deviceScaleFactor: base.viewport.deviceScaleFactor }
189
- : {}),
190
- }
191
- : { ...base.viewport };
192
- resolved = {
193
- preset,
194
- viewport,
195
- isMobile: d.isMobile !== undefined ? !!d.isMobile : base.isMobile,
196
- hasTouch: d.hasTouch !== undefined ? !!d.hasTouch : base.hasTouch || touchMode,
197
- touchMode,
198
- orientation: normalizeDeviceOrientation(d.orientation) ||
199
- orientationFromViewport(viewport),
200
- };
201
- }
202
- if (!resolved)
203
- return undefined;
204
- const want = orientationOverride ||
205
- (typeof input === "object" && input && "orientation" in input
206
- ? normalizeDeviceOrientation(input.orientation)
207
- : undefined);
208
- if (want)
209
- return applyOrientation(resolved, want);
210
- if (!resolved.orientation) {
211
- resolved = {
212
- ...resolved,
213
- orientation: orientationFromViewport(resolved.viewport),
214
- };
215
- }
216
- return resolved;
217
- }
218
- /**
219
- * Carry-forward for device fixtures (mirror {@link applyTodoPhase}).
220
- * - clear: hideDevice / clearDevice / device("desktop") with clear intent
221
- * - set: author set device/viewport/touch this phase
222
- * - keep: omit - preserve previous (whole episode remain)
223
- */
224
- export function applyDevicePhase(prev, phase) {
225
- const sync = phase.deviceSync ||
226
- (phase.device ? "set" : "keep");
227
- if (sync === "clear") {
228
- return { device: resolveDeviceState("desktop", false), sync: "clear" };
229
- }
230
- if (sync === "set") {
231
- if (phase.device)
232
- return { device: phase.device, sync: "set" };
233
- return { device: resolveDeviceState("desktop", false), sync: "clear" };
234
- }
235
- return { device: prev, sync: "keep" };
236
- }
237
- /**
238
- * The `#wg-ring` / `#wg-ring-label` CSS (tone/size/weight variants) - the
239
- * actual visual language `cli.ts`'s own demo stepper paints with. Extracted
240
- * here (not left as a private `cli.ts` constant) so anything else wanting the
241
- * SAME "waygraph vision" ring can import pure CSS text, not the rendering
242
- * engine around it - `cli.ts` runs `main().catch(...)` unconditionally at
243
- * module load with no `import.meta.url` guard, so importing anything from it
244
- * is unsafe as a library dependency (see `pilot-overlay.ts`'s own doc
245
- * comment for the same reasoning re: the badge/panel). This file has always
246
- * been the pure, side-effect-free home for tone/size/weight - the CSS that
247
- * renders them belongs alongside it for the same reason. `cli.ts` imports
248
- * this constant instead of keeping its own duplicate copy.
249
- */
250
- export const WAYGRAPH_RING_CSS = "#wg-ring{position:fixed;z-index:2147483646;pointer-events:none;opacity:0;" +
251
- "border:2.5px solid #7C3AED;border-radius:10px;" +
252
- // Opacity fade only - NEVER transition border-color/box-shadow. Tone swaps
253
- // (auto gray -> planned purple -> warning yellow) must snap instantly;
254
- // color transitions read as a muddy gray/purple/yellow morph on ring 2+.
255
- "box-shadow:0 0 0 4px rgba(124,58,237,.16);transition:opacity .3s ease;}" +
256
- // Planned (stubs / fixtures / YAP / instruction.highlights) = purple.
257
- // Automation (verify fallback, unmatched fill/click) = gray - operators
258
- // can ignore engine checks and watch purple + semantic tones.
259
- "#wg-ring[data-tone=planned]{border-color:#7C3AED;box-shadow:0 0 0 4px rgba(124,58,237,.16);}" +
260
- "#wg-ring[data-tone=auto]{border-color:#9CA3AF;box-shadow:0 0 0 4px rgba(156,163,175,.28);}" +
261
- "#wg-ring[data-tone=info]{border-color:#3B82F6;box-shadow:0 0 0 4px rgba(59,130,246,.22);}" +
262
- "#wg-ring[data-tone=warning]{border-color:#EAB308;box-shadow:0 0 0 4px rgba(234,179,8,.22);}" +
263
- "#wg-ring[data-tone=danger]{border-color:#EF4444;box-shadow:0 0 0 4px rgba(239,68,68,.22);}" +
264
- "#wg-ring[data-tone=success]{border-color:#22C55E;box-shadow:0 0 0 4px rgba(34,197,94,.22);}" +
265
- // Real, direct user request: a visible color distinction between a real
266
- // Playwright-driven action (auto gray, unchanged) and a pure DOM
267
- // inspection (this - orange).
268
- "#wg-ring[data-tone=orange]{border-color:#F97316;box-shadow:0 0 0 4px rgba(249,115,22,.22);}" +
269
- // A real element, not a ::after pseudo-element - a pseudo-element's
270
- // position is CSS-relative to the ring's own box (left:0 always meant
271
- // "the ring's own left edge"), so it had no way to be clamped back onto
272
- // screen when that box sat near a viewport edge - the label's text just
273
- // ran off, invisibly, with no overflow guard at all. A real sibling can
274
- // be measured (its actual rendered width) and repositioned in JS -
275
- // pushed back onto screen, same width, never shrunk. See
276
- // window.__wgPositionRing in cli.ts's own installOverlay.
277
- "#wg-ring-label{position:fixed;z-index:2147483646;pointer-events:none;opacity:0;" +
278
- "max-width:min(360px,70vw);white-space:normal;padding:6px 10px;border-radius:7px;background:#7C3AED;color:#fff;" +
279
- "font:600 12px/1.35 system-ui,sans-serif;transition:opacity .3s ease;}" +
280
- "#wg-ring-label[data-tone=planned]{background:#7C3AED;color:#fff;}" +
281
- "#wg-ring-label[data-tone=auto]{background:#6B7280;color:#fff;}" +
282
- "#wg-ring-label[data-tone=info]{background:#2563EB;color:#fff;}" +
283
- "#wg-ring-label[data-tone=warning]{background:#EAB308;color:#1c1917;}" +
284
- "#wg-ring-label[data-tone=danger]{background:#DC2626;color:#fff;}" +
285
- "#wg-ring-label[data-tone=success]{background:#16A34A;color:#fff;}" +
286
- "#wg-ring-label[data-tone=orange]{background:#EA580C;color:#fff;}" +
287
- // size: ring pad + label font; weight: label boldness
288
- "#wg-ring[data-size=sm]{border-width:1.5px;border-radius:8px;}" +
289
- "#wg-ring[data-size=lg]{border-width:4px;border-radius:12px;}" +
290
- "#wg-ring-label[data-size=sm]{font-size:10px;line-height:1.25;padding:4px 7px;border-radius:5px;}" +
291
- "#wg-ring-label[data-size=lg]{font-size:16px;line-height:1.35;padding:8px 14px;border-radius:9px;max-width:min(480px,85vw);}" +
292
- "#wg-ring-label[data-weight=bold]{font-weight:800;}" +
293
- "#wg-ring-label[data-weight=normal]{font-weight:600;}";
294
- /** Apply Flow defaults under per-slot authored values (slot wins when set). */
295
- export function applyHighlightStyleDefaults(slot, defaults) {
296
- const tone = slot.tone !== undefined && slot.tone !== ""
297
- ? normalizeHighlightTone(slot.tone)
298
- : defaults?.tone !== undefined
299
- ? normalizeHighlightTone(defaults.tone)
300
- : "planned";
301
- const size = slot.size !== undefined && slot.size !== ""
302
- ? normalizeHighlightSize(slot.size)
303
- : defaults?.size !== undefined
304
- ? normalizeHighlightSize(defaults.size)
305
- : "md";
306
- const weight = slot.weight !== undefined && slot.weight !== ""
307
- ? normalizeHighlightWeight(slot.weight)
308
- : defaults?.weight !== undefined
309
- ? normalizeHighlightWeight(defaults.weight)
310
- : "normal";
311
- return { ...slot, tone, size, weight };
312
- }
313
- const TONE_ICONS = {
314
- planned: "",
315
- auto: "",
316
- info: "i",
317
- warning: "!",
318
- danger: "x",
319
- success: "+",
320
- orange: "",
321
- };
322
- /** Map author aliases (`error` -> danger) to a canonical tone. */
323
- export function normalizeHighlightTone(raw) {
324
- if (typeof raw !== "string")
325
- return "planned";
326
- const t = raw.trim().toLowerCase();
327
- if (t === "auto" || t === "automation" || t === "engine")
328
- return "auto";
329
- if (t === "info" || t === "blue")
330
- return "info";
331
- if (t === "warning" || t === "warn" || t === "yellow")
332
- return "warning";
333
- if (t === "danger" || t === "error" || t === "red")
334
- return "danger";
335
- if (t === "success" || t === "ok" || t === "green")
336
- return "success";
337
- if (t === "orange" || t === "amber" || t === "dom" || t === "inspect")
338
- return "orange";
339
- if (t === "planned" || t === "purple" || t === "authored")
340
- return "planned";
341
- return "planned";
342
- }
343
- /** Map size aliases to sm/md/lg. */
344
- export function normalizeHighlightSize(raw) {
345
- if (typeof raw !== "string")
346
- return "md";
347
- const t = raw.trim().toLowerCase();
348
- if (t === "sm" || t === "s" || t === "small" || t === "tiny")
349
- return "sm";
350
- if (t === "lg" || t === "l" || t === "large" || t === "big" || t === "xl")
351
- return "lg";
352
- return "md";
353
- }
354
- /** Map weight aliases to normal/bold. */
355
- export function normalizeHighlightWeight(raw) {
356
- if (typeof raw !== "string")
357
- return "normal";
358
- const t = raw.trim().toLowerCase();
359
- if (t === "bold" || t === "strong" || t === "heavy" || t === "b")
360
- return "bold";
361
- return "normal";
362
- }
363
- /** Prefix label with a short ASCII icon for semantic tones. */
364
- export function toneIconPrefix(tone) {
365
- const icon = TONE_ICONS[tone];
366
- return icon ? `[${icon}] ` : "";
367
- }
368
- /** Default fixture dwell when `duration: true`. */
369
- export const FIXTURE_DURATION_MS_DEFAULT = 2000;
370
- /** Default fixture dwell under `--fast` when duration is enabled. */
371
- export const FIXTURE_DURATION_FAST_MS_DEFAULT = 600;
372
- /** Default fixture dwell under demo pace `slow` (gaps / wrongs review). */
373
- export const FIXTURE_DURATION_SLOW_MS_DEFAULT = 4000;
374
- /** @deprecated Prefer FIXTURE_DURATION_MS_DEFAULT */
375
- export const SLIDE_DURATION_MS_DEFAULT = FIXTURE_DURATION_MS_DEFAULT;
376
- /** @deprecated Prefer FIXTURE_DURATION_FAST_MS_DEFAULT */
377
- export const SLIDE_DURATION_FAST_MS_DEFAULT = FIXTURE_DURATION_FAST_MS_DEFAULT;
378
- export function normalizeDemoPace(raw) {
379
- if (typeof raw === "number" && Number.isFinite(raw) && raw > 0)
380
- return raw;
381
- if (typeof raw === "string") {
382
- const t = raw.trim().toLowerCase();
383
- if (t === "blitz" || t === "ff")
384
- return "blitz";
385
- if (t === "fast" || t === "quick")
386
- return "fast";
387
- if (t === "slow" || t === "careful" || t === "review")
388
- return "slow";
389
- if (t === "normal" || t === "default" || t === "1x")
390
- return "normal";
391
- // "2x" / "0.5x"
392
- if (t.endsWith("x")) {
393
- const n = Number(t.slice(0, -1));
394
- if (Number.isFinite(n) && n > 0)
395
- return n;
396
- }
397
- const n = Number(t);
398
- if (Number.isFinite(n) && n > 0)
399
- return n;
400
- }
401
- return "normal";
402
- }
403
- /** Named / numeric pace -> scale vs normal (1 = normal). Absolute ms (>20) maps via default dwell. */
404
- export function demoPaceScale(pace) {
405
- if (typeof pace === "number") {
406
- if (pace > 20)
407
- return Math.min(10, Math.max(0.05, pace / FIXTURE_DURATION_MS_DEFAULT));
408
- return Math.min(10, Math.max(0.05, pace));
409
- }
410
- if (pace === "blitz")
411
- return 0.05;
412
- if (pace === "fast")
413
- return 0.33;
414
- if (pace === "slow")
415
- return 2;
416
- return 1;
417
- }
418
- export function demoPaceIsBlitz(pace) {
419
- return pace === "blitz" || (typeof pace === "number" && pace > 0 && pace <= 0.1);
420
- }
421
- /** True when pace should use the short-gate / fast fixture path (not slow authored). */
422
- export function demoPaceIsFast(pace) {
423
- if (demoPaceIsBlitz(pace))
424
- return true;
425
- if (pace === "fast")
426
- return true;
427
- if (typeof pace === "number" && pace <= 20)
428
- return pace < 0.75;
429
- return false;
430
- }
431
- export function demoPaceIsSlow(pace) {
432
- if (pace === "slow")
433
- return true;
434
- if (typeof pace === "number") {
435
- if (pace > 20)
436
- return pace >= 3000;
437
- return pace > 1.25;
438
- }
439
- return false;
440
- }
441
- /** Autoplay / Next-gate ms for this pace (base is WAYGRAPH_AUTOPLAY_MS or 1800). */
442
- export function demoPaceGateMs(pace, baseAutoplayMs) {
443
- if (demoPaceIsBlitz(pace))
444
- return Math.min(80, baseAutoplayMs);
445
- if (typeof pace === "number" && pace > 20) {
446
- return Math.min(30000, Math.max(80, Math.round(pace)));
447
- }
448
- const scale = demoPaceScale(pace);
449
- return Math.min(30000, Math.max(80, Math.round(baseAutoplayMs * scale)));
450
- }
451
- /**
452
- * Short chip for the demo panel (e.g. `2.5x`, `4500ms`, `slow`).
453
- * Always printable - numbers speak in the UI, not only in code.
454
- */
455
- export function formatDemoPaceBadge(pace, baseAutoplayMs = 1800) {
456
- const p = normalizeDemoPace(pace);
457
- if (typeof p === "number" && p > 20)
458
- return `${Math.round(p)}ms`;
459
- if (typeof p === "number") {
460
- const s = Number.isInteger(p) ? String(p) : String(Math.round(p * 100) / 100);
461
- return `${s}x`;
462
- }
463
- if (p === "blitz")
464
- return "blitz";
465
- if (p === "fast")
466
- return "fast";
467
- if (p === "slow")
468
- return "slow";
469
- void baseAutoplayMs;
470
- return "1x";
471
- }
472
- /**
473
- * Loud one-line pace description for panel + console
474
- * (e.g. `pace 2.5x (~4500ms gates)` / `pace 4500ms gates (~2.25x)`).
475
- */
476
- export function formatDemoPaceLabel(pace, baseAutoplayMs = 1800) {
477
- const p = normalizeDemoPace(pace);
478
- const gate = demoPaceGateMs(p, baseAutoplayMs);
479
- const scale = demoPaceScale(p);
480
- const scaleTxt = Number.isInteger(scale) ? String(scale) : (Math.round(scale * 100) / 100).toString();
481
- if (typeof p === "number" && p > 20) {
482
- return `pace ${Math.round(p)}ms gates (~${scaleTxt}x)`;
483
- }
484
- if (typeof p === "number") {
485
- const s = Number.isInteger(p) ? String(p) : String(Math.round(p * 100) / 100);
486
- return `pace ${s}x (~${gate}ms gates)`;
487
- }
488
- if (p === "blitz")
489
- return `pace blitz (~${gate}ms, skip theater)`;
490
- if (p === "fast")
491
- return `pace fast (~${scaleTxt}x, ~${gate}ms gates)`;
492
- if (p === "slow")
493
- return `pace slow (~${scaleTxt}x, ~${gate}ms gates)`;
494
- return `pace normal (1x, ~${gate}ms gates)`;
495
- }
496
- /**
497
- * Resolve min dwell ms for any fixture (stub / flow fixture / yap slide),
498
- * or `null` when duration is unset (caller keeps legacy timing).
499
- *
500
- * Authoring wins: an explicit slow/numeric pace is not crushed by CLI `--fast`
501
- * (`gatesFast`). `--fast` only shortens when pace is unset/normal.
502
- */
503
- export function resolveFixtureDwellMs(fixture, opts) {
504
- if (fixture.duration === undefined || fixture.duration === false)
505
- return null;
506
- const pace = opts?.pace !== undefined && opts.pace !== null
507
- ? normalizeDemoPace(opts.pace)
508
- : opts?.gatesFast
509
- ? "fast"
510
- : "normal";
511
- const fastMs = typeof fixture.fastMode === "number" && Number.isFinite(fixture.fastMode) && fixture.fastMode >= 0
512
- ? fixture.fastMode
513
- : FIXTURE_DURATION_FAST_MS_DEFAULT;
514
- const normalMs = fixture.duration === true
515
- ? FIXTURE_DURATION_MS_DEFAULT
516
- : typeof fixture.duration === "number" && Number.isFinite(fixture.duration) && fixture.duration >= 0
517
- ? fixture.duration
518
- : FIXTURE_DURATION_MS_DEFAULT;
519
- if (typeof pace === "number") {
520
- if (pace > 20)
521
- return Math.round(pace);
522
- return Math.max(0, Math.round(normalMs * Math.min(10, Math.max(0.05, pace))));
523
- }
524
- if (pace === "blitz" || pace === "fast")
525
- return fastMs;
526
- // CLI --fast only when author left pace at normal
527
- if (opts?.gatesFast && pace === "normal")
528
- return fastMs;
529
- if (pace === "slow")
530
- return Math.max(normalMs, FIXTURE_DURATION_SLOW_MS_DEFAULT);
531
- return normalMs;
532
- }
533
- /** @deprecated Prefer {@link resolveFixtureDwellMs} */
534
- export function resolveSlideDwellMs(slide, opts) {
535
- return resolveFixtureDwellMs(slide, opts);
536
- }
537
- /**
538
- * Effective demo pace for a step: block override > flow episode > normal.
539
- * Opaque FF / wasFastForward always win as blitz.
540
- */
541
- export function resolveStepDemoPace(opts) {
542
- if (opts.fastForward || opts.wasFastForward)
543
- return "blitz";
544
- if (opts.blockPace != null && opts.blockPace !== "") {
545
- return normalizeDemoPace(opts.blockPace);
546
- }
547
- if (opts.flowPace != null && opts.flowPace !== "") {
548
- return normalizeDemoPace(opts.flowPace);
549
- }
550
- return "normal";
551
- }
552
- function isStubPhase(v) {
553
- return !!v && typeof v === "object" && !Array.isArray(v);
554
- }
555
- function pickDurationFields(base, patch) {
556
- const out = {};
557
- const duration = patch?.duration !== undefined ? patch.duration : base?.duration;
558
- const fastMode = patch?.fastMode !== undefined ? patch.fastMode : base?.fastMode;
559
- if (duration !== undefined)
560
- out.duration = duration;
561
- if (fastMode !== undefined)
562
- out.fastMode = fastMode;
563
- return out;
564
- }
565
- function pickStyleFields(base, patch) {
566
- const out = {};
567
- const tone = patch?.tone !== undefined ? patch.tone : base?.tone;
568
- const size = patch?.size !== undefined ? patch.size : base?.size;
569
- const weight = patch?.weight !== undefined ? patch.weight : base?.weight;
570
- if (tone !== undefined)
571
- out.tone = normalizeHighlightTone(tone);
572
- if (size !== undefined)
573
- out.size = normalizeHighlightSize(size);
574
- if (weight !== undefined)
575
- out.weight = normalizeHighlightWeight(weight);
576
- return out;
577
- }
578
- function pickZoom(base, patch) {
579
- const zoom = patch?.zoom !== undefined ? patch.zoom : base?.zoom;
580
- if (zoom !== undefined && Number.isFinite(zoom) && zoom > 0)
581
- return { zoom };
582
- return {};
583
- }
584
- function pickFxFields(base, patch) {
585
- const out = {};
586
- const focus = patch?.focus !== undefined ? patch.focus : base?.focus;
587
- const color = patch?.color !== undefined ? patch.color : base?.color;
588
- const cursor = patch?.cursor !== undefined ? patch.cursor : base?.cursor;
589
- const todo = patch?.todo !== undefined ? patch.todo : base?.todo;
590
- const gesture = patch?.gesture !== undefined ? patch.gesture : base?.gesture;
591
- if (focus !== undefined)
592
- out.focus = !!focus;
593
- if (color !== undefined && String(color).trim())
594
- out.color = String(color).trim();
595
- if (cursor !== undefined)
596
- out.cursor = !!cursor;
597
- if (todo !== undefined && String(todo).trim())
598
- out.todo = String(todo).trim();
599
- if (gesture === "tap" || gesture === "hold" || gesture === "click" || gesture === "swipe") {
600
- out.gesture = gesture;
601
- }
602
- return out;
603
- }
604
- function mergeSlot(base, patch) {
605
- if (!base && !patch)
606
- return null;
607
- const dwell = pickDurationFields(base, patch);
608
- const style = pickStyleFields(base, patch);
609
- const zoom = pickZoom(base, patch);
610
- const fx = pickFxFields(base, patch);
611
- if (!base && patch) {
612
- if (!patch.selector)
613
- return null;
614
- return {
615
- selector: patch.selector,
616
- label: patch.label,
617
- ...(patch.detail ? { detail: patch.detail } : {}),
618
- ...(patch.tag ? { tag: patch.tag } : {}),
619
- ...style,
620
- ...dwell,
621
- ...zoom,
622
- ...fx,
623
- };
624
- }
625
- if (base && !patch) {
626
- return {
627
- ...base,
628
- ...(base.tone ? { tone: normalizeHighlightTone(base.tone) } : {}),
629
- ...(base.size ? { size: normalizeHighlightSize(base.size) } : {}),
630
- ...(base.weight ? { weight: normalizeHighlightWeight(base.weight) } : {}),
631
- ...pickZoom(base, undefined),
632
- ...pickFxFields(base, undefined),
633
- };
634
- }
635
- return {
636
- selector: patch.selector ?? base.selector,
637
- label: patch.label,
638
- ...(patch.detail !== undefined
639
- ? { detail: patch.detail }
640
- : base.detail
641
- ? { detail: base.detail }
642
- : {}),
643
- ...(patch.tag !== undefined ? { tag: patch.tag } : base.tag ? { tag: base.tag } : {}),
644
- ...style,
645
- ...dwell,
646
- ...zoom,
647
- ...fx,
648
- };
649
- }
650
- /** Normalize checklist dock side. */
651
- export function normalizeTodoPos(raw) {
652
- const s = String(raw ?? "")
653
- .trim()
654
- .toLowerCase();
655
- if (s === "left" || s === "l")
656
- return "left";
657
- if (s === "right" || s === "r")
658
- return "right";
659
- return undefined;
660
- }
661
- /** Normalize dock list style. */
662
- export function normalizeTodoStyle(raw) {
663
- const s = String(raw ?? "")
664
- .trim()
665
- .toLowerCase();
666
- if (s === "sequential" || s === "seq" || s === "steps" || s === "step")
667
- return "sequential";
668
- if (s === "checklist" || s === "check" || s === "checks" || s === "box")
669
- return "checklist";
670
- if (s === "bullets" ||
671
- s === "bullet" ||
672
- s === "list" ||
673
- s === "ul" ||
674
- s === "points" ||
675
- s === "plain") {
676
- return "bullets";
677
- }
678
- return undefined;
679
- }
680
- /**
681
- * Normalize checklist rows; apply todoIndex for sequential done/current.
682
- * In checklist / bullets mode, todoIndex is ignored unless items omit explicit done
683
- * (bullets never auto-mark current).
684
- */
685
- export function normalizeTodos(todos, todoIndex, style = "sequential") {
686
- if (!todos || todos.length === 0)
687
- return [];
688
- const idx = todoIndex !== undefined && Number.isFinite(todoIndex) ? Math.floor(todoIndex) : undefined;
689
- return todos.map((t, i) => {
690
- const text = typeof t === "string" ? t.trim() : String(t?.text ?? "").trim();
691
- const item = { text: text || `Item ${i + 1}` };
692
- if (typeof t === "object" && t) {
693
- if (t.done !== undefined)
694
- item.done = !!t.done;
695
- if (t.current !== undefined)
696
- item.current = !!t.current;
697
- const idRaw = t.id ?? t.name;
698
- if (idRaw != null && String(idRaw).trim())
699
- item.id = String(idRaw).trim();
700
- }
701
- if (style === "checklist") {
702
- if (item.done === undefined)
703
- item.done = false;
704
- item.current = false;
705
- return item;
706
- }
707
- if (style === "bullets") {
708
- // Flat list: no walkthrough current; keep explicit done if authored.
709
- item.current = false;
710
- return item;
711
- }
712
- if (idx !== undefined) {
713
- if (item.done === undefined)
714
- item.done = i < idx;
715
- if (item.current === undefined)
716
- item.current = i === idx;
717
- }
718
- return item;
719
- });
720
- }
721
- /** Build the floating dock from bag / phase fields. */
722
- export function buildTodoDock(opts) {
723
- const style = normalizeTodoStyle(opts.todoStyle) || "sequential";
724
- const pos = normalizeTodoPos(opts.todoPos);
725
- const dockId = opts.todoId != null && String(opts.todoId).trim() ? String(opts.todoId).trim() : undefined;
726
- if (opts.todoGroups && opts.todoGroups.length) {
727
- const groups = opts.todoGroups
728
- .map((g) => {
729
- const gs = normalizeTodoStyle(g.style) || style;
730
- const items = normalizeTodos(g.items, g.todoIndex, gs);
731
- if (!items.length)
732
- return null;
733
- const group = { style: gs, items };
734
- if (g.title && String(g.title).trim())
735
- group.title = String(g.title).trim();
736
- const gid = g.id ?? g.name;
737
- if (gid != null && String(gid).trim())
738
- group.id = String(gid).trim();
739
- return group;
740
- })
741
- .filter((g) => !!g);
742
- if (!groups.length)
743
- return undefined;
744
- const dock = { style, groups };
745
- if (pos)
746
- dock.pos = pos;
747
- if (dockId)
748
- dock.id = dockId;
749
- return dock;
750
- }
751
- const items = normalizeTodos(opts.todos, opts.todoIndex, style);
752
- if (!items.length)
753
- return undefined;
754
- const group = { style, items };
755
- // Title once on the dock - not also on the lone group (avoids duplicate headings).
756
- const dock = { style, groups: [group] };
757
- if (opts.todoTitle && String(opts.todoTitle).trim()) {
758
- dock.title = String(opts.todoTitle).trim();
759
- }
760
- if (pos)
761
- dock.pos = pos;
762
- if (dockId)
763
- dock.id = dockId;
764
- return dock;
765
- }
766
- /**
767
- * Carry-forward rules for the floating todo dock across demo steps.
768
- * - clear: author called hideTodos / empty todos()
769
- * - set: author authored a new dock this phase
770
- * - keep: author omitted todos — preserve previous dock (do not wipe)
771
- */
772
- export function applyTodoPhase(prev, phase) {
773
- const sync = phase.todoSync ||
774
- (phase.todoDock ? "set" : phase.todos && phase.todos.length ? "set" : "keep");
775
- if (sync === "clear")
776
- return { dock: undefined, sync: "clear" };
777
- if (sync === "set") {
778
- if (phase.todoDock)
779
- return { dock: phase.todoDock, sync: "set" };
780
- if (phase.todos && phase.todos.length) {
781
- const dock = buildTodoDock({
782
- todos: phase.todos,
783
- ...(phase.todoPos ? { todoPos: phase.todoPos } : {}),
784
- ...(phase.todoId ? { todoId: phase.todoId } : {}),
785
- });
786
- return { dock, sync: "set" };
787
- }
788
- return { dock: undefined, sync: "clear" };
789
- }
790
- return { dock: prev, sync: "keep" };
791
- }
792
- /**
793
- * Advance every sequential group to `index` (capped). Checklist / bullets unchanged.
794
- * Used while cycling rings / fill/click stubs so the walkthrough moves.
795
- */
796
- export function advanceTodoDock(dock, index) {
797
- if (!dock || !dock.groups.length)
798
- return dock;
799
- const i = Number.isFinite(index) ? Math.max(0, Math.floor(index)) : 0;
800
- return {
801
- ...dock,
802
- groups: dock.groups.map((g) => {
803
- if (g.style !== "sequential")
804
- return g;
805
- const capped = Math.min(i, Math.max(0, g.items.length - 1));
806
- const raw = g.items.map((it) => {
807
- const row = { text: it.text };
808
- if (it.id)
809
- row.id = it.id;
810
- else if (it.name)
811
- row.id = it.name;
812
- return row;
813
- });
814
- return {
815
- ...g,
816
- items: normalizeTodos(raw, capped, "sequential"),
817
- };
818
- }),
819
- };
820
- }
821
- /**
822
- * Mark sequential groups complete (all done, none current) - e.g. end of step.
823
- * Checklist / bullets groups keep explicit done flags.
824
- */
825
- export function completeSequentialTodoDock(dock) {
826
- if (!dock)
827
- return dock;
828
- return {
829
- ...dock,
830
- groups: dock.groups.map((g) => {
831
- if (g.style !== "sequential")
832
- return g;
833
- return {
834
- ...g,
835
- items: g.items.map((it) => {
836
- const row = { text: it.text, done: true, current: false };
837
- if (it.id)
838
- row.id = it.id;
839
- else if (it.name)
840
- row.id = it.name;
841
- return row;
842
- }),
843
- };
844
- }),
845
- };
846
- }
847
- function createStubCtx(bag, opts) {
848
- return {
849
- mem: opts.mem,
850
- out: opts.out,
851
- error: opts.error,
852
- highlights(slots) {
853
- bag.highlights = { ...(slots || {}) };
854
- },
855
- ring(id, stub) {
856
- bag.highlights = { ...bag.highlights, [id]: stub };
857
- },
858
- todos(a, b, c) {
859
- bag.todoTouched = true;
860
- bag.hideTodos = false;
861
- delete bag.todoGroups;
862
- if (typeof a === "string") {
863
- const id = String(a).trim();
864
- if (id)
865
- bag.todoId = id;
866
- else
867
- delete bag.todoId;
868
- const items = (Array.isArray(b) ? b : []);
869
- bag.todos = items;
870
- const opts = (c && typeof c === "object" ? c : {});
871
- if (opts.title != null)
872
- bag.todoTitle = String(opts.title);
873
- if (opts.index != null)
874
- bag.todoIndex = Number(opts.index);
875
- const st = normalizeTodoStyle(opts.style);
876
- if (st)
877
- bag.todoStyle = st;
878
- const pos = normalizeTodoPos(opts.pos);
879
- if (pos)
880
- bag.todoPos = pos;
881
- bag.todoParallel = opts.parallel === true;
882
- if (!items.length)
883
- bag.hideTodos = true;
884
- return;
885
- }
886
- bag.todos = a;
887
- // Legacy list-only form: leave parallel unset (CLI treats as replace).
888
- delete bag.todoParallel;
889
- if (!a || !a.length)
890
- bag.hideTodos = true;
891
- },
892
- todoIndex(n) {
893
- bag.todoIndex = n;
894
- },
895
- todoTitle(text) {
896
- bag.todoTitle = String(text ?? "");
897
- },
898
- todoId(id) {
899
- bag.todoTouched = true;
900
- const cleaned = String(id ?? "").trim();
901
- if (cleaned)
902
- bag.todoId = cleaned;
903
- else
904
- delete bag.todoId;
905
- },
906
- todoStyle(style) {
907
- const s = normalizeTodoStyle(style);
908
- if (s)
909
- bag.todoStyle = s;
910
- },
911
- todoGroups(groups) {
912
- bag.todoTouched = true;
913
- bag.hideTodos = false;
914
- bag.todoGroups = groups;
915
- if (!groups || !groups.length)
916
- bag.hideTodos = true;
917
- },
918
- hideTodos() {
919
- bag.todoTouched = true;
920
- bag.hideTodos = true;
921
- bag.todos = [];
922
- delete bag.todoGroups;
923
- },
924
- clearTodos() {
925
- bag.todoTouched = true;
926
- bag.hideTodos = true;
927
- bag.todos = [];
928
- delete bag.todoGroups;
929
- },
930
- device(preset) {
931
- bag.deviceTouched = true;
932
- bag.hideDevice = false;
933
- const resolved = resolveDeviceState(preset, bag.touchOverride, bag.orientationOverride);
934
- if (resolved) {
935
- bag.device = resolved;
936
- if (resolved.preset === "desktop" && !resolved.touchMode) {
937
- bag.hideDevice = true;
938
- }
939
- }
940
- },
941
- viewport(box) {
942
- bag.deviceTouched = true;
943
- bag.hideDevice = false;
944
- const resolved = resolveDeviceState(box, bag.touchOverride, bag.orientationOverride);
945
- if (resolved)
946
- bag.device = resolved;
947
- },
948
- touch(on) {
949
- bag.deviceTouched = true;
950
- const enabled = on === undefined ? true : !!on;
951
- bag.touchOverride = enabled;
952
- if (enabled)
953
- bag.hideDevice = false;
954
- if (bag.device) {
955
- bag.device = {
956
- ...bag.device,
957
- touchMode: enabled,
958
- hasTouch: bag.device.hasTouch || enabled,
959
- };
960
- if (enabled)
961
- bag.hideDevice = false;
962
- }
963
- else if (enabled) {
964
- const base = resolveDeviceState("desktop", true, bag.orientationOverride);
965
- if (base)
966
- bag.device = { ...base, touchMode: true, hasTouch: true };
967
- }
968
- },
969
- orientation(o) {
970
- const want = normalizeDeviceOrientation(o);
971
- if (!want)
972
- return;
973
- bag.deviceTouched = true;
974
- bag.hideDevice = false;
975
- bag.orientationOverride = want;
976
- const base = bag.device ||
977
- resolveDeviceState("mobile", bag.touchOverride) ||
978
- resolveDeviceState("mobile");
979
- if (base)
980
- bag.device = applyOrientation(base, want);
981
- },
982
- landscape() {
983
- this.orientation("landscape");
984
- },
985
- portrait() {
986
- this.orientation("portrait");
987
- },
988
- hideDevice() {
989
- bag.deviceTouched = true;
990
- bag.hideDevice = true;
991
- bag.device = resolveDeviceState("desktop", false);
992
- bag.touchOverride = false;
993
- bag.orientationOverride = undefined;
994
- },
995
- clearDevice() {
996
- bag.deviceTouched = true;
997
- bag.hideDevice = true;
998
- bag.device = resolveDeviceState("desktop", false);
999
- bag.touchOverride = false;
1000
- bag.orientationOverride = undefined;
1001
- },
1002
- zoom(n) {
1003
- if (Number.isFinite(n) && n > 0)
1004
- bag.zoom = n;
1005
- },
1006
- zoomOut(keep) {
1007
- bag.zoomOut = !!keep;
1008
- },
1009
- title(text) {
1010
- bag.title = String(text ?? "");
1011
- },
1012
- banner(text) {
1013
- bag.title = String(text ?? "");
1014
- },
1015
- todoPos(side) {
1016
- const p = normalizeTodoPos(side);
1017
- if (p)
1018
- bag.todoPos = p;
1019
- },
1020
- todoDockUi(opts) {
1021
- if (!opts || typeof opts !== "object")
1022
- return;
1023
- bag.todoDockUi = { ...(bag.todoDockUi || {}), ...opts };
1024
- },
1025
- todoDockFull() {
1026
- bag.todoDockUi = {
1027
- compact: false,
1028
- collision: false,
1029
- behindRing: false,
1030
- };
1031
- },
1032
- set(partial) {
1033
- if (partial.highlights)
1034
- bag.highlights = { ...partial.highlights };
1035
- if (partial.todos !== undefined) {
1036
- bag.todoTouched = true;
1037
- bag.hideTodos = !partial.todos || !partial.todos.length;
1038
- bag.todos = partial.todos;
1039
- if (partial.todoGroups === undefined)
1040
- delete bag.todoGroups;
1041
- }
1042
- if (partial.todoIndex !== undefined)
1043
- bag.todoIndex = partial.todoIndex;
1044
- if (partial.todoTitle !== undefined)
1045
- bag.todoTitle = String(partial.todoTitle ?? "");
1046
- if (partial.todoId !== undefined) {
1047
- bag.todoTouched = true;
1048
- const cleaned = String(partial.todoId ?? "").trim();
1049
- if (cleaned)
1050
- bag.todoId = cleaned;
1051
- else
1052
- delete bag.todoId;
1053
- }
1054
- const ts = normalizeTodoStyle(partial.todoStyle);
1055
- if (ts)
1056
- bag.todoStyle = ts;
1057
- if (partial.todoGroups !== undefined) {
1058
- bag.todoTouched = true;
1059
- bag.hideTodos = !partial.todoGroups || !partial.todoGroups.length;
1060
- bag.todoGroups = partial.todoGroups;
1061
- }
1062
- if (partial.touch !== undefined) {
1063
- bag.deviceTouched = true;
1064
- bag.touchOverride = !!partial.touch;
1065
- }
1066
- if (partial.orientation !== undefined) {
1067
- const want = normalizeDeviceOrientation(partial.orientation);
1068
- if (want) {
1069
- bag.deviceTouched = true;
1070
- bag.hideDevice = false;
1071
- bag.orientationOverride = want;
1072
- const base = bag.device ||
1073
- resolveDeviceState("mobile", bag.touchOverride) ||
1074
- resolveDeviceState("mobile");
1075
- if (base)
1076
- bag.device = applyOrientation(base, want);
1077
- }
1078
- }
1079
- if (partial.device !== undefined) {
1080
- bag.deviceTouched = true;
1081
- bag.hideDevice = false;
1082
- const resolved = resolveDeviceState(partial.device, bag.touchOverride, bag.orientationOverride);
1083
- if (resolved) {
1084
- bag.device = resolved;
1085
- if (resolved.preset === "desktop" && !resolved.touchMode)
1086
- bag.hideDevice = true;
1087
- }
1088
- }
1089
- if (partial.viewport !== undefined) {
1090
- bag.deviceTouched = true;
1091
- bag.hideDevice = false;
1092
- const resolved = resolveDeviceState(partial.viewport, bag.touchOverride, bag.orientationOverride);
1093
- if (resolved)
1094
- bag.device = resolved;
1095
- }
1096
- if (partial.zoom !== undefined && Number.isFinite(partial.zoom) && partial.zoom > 0) {
1097
- bag.zoom = partial.zoom;
1098
- }
1099
- if (partial.zoomOut !== undefined)
1100
- bag.zoomOut = !!partial.zoomOut;
1101
- if (partial.title !== undefined)
1102
- bag.title = String(partial.title ?? "");
1103
- const tp = normalizeTodoPos(partial.todoPos);
1104
- if (tp)
1105
- bag.todoPos = tp;
1106
- if (partial.todoDockUi !== undefined && partial.todoDockUi && typeof partial.todoDockUi === "object") {
1107
- bag.todoDockUi = { ...(bag.todoDockUi || {}), ...partial.todoDockUi };
1108
- }
1109
- },
1110
- };
1111
- }
1112
- /** Lift legacy per-slot todos (0.12.23) onto the phase bag once. */
1113
- function liftLegacySlotTodos(bag) {
1114
- if (bag.todos && bag.todos.length)
1115
- return;
1116
- for (const stub of Object.values(bag.highlights)) {
1117
- const legacy = stub;
1118
- if (legacy.todos && legacy.todos.length) {
1119
- bag.todos = legacy.todos;
1120
- if (legacy.todoIndex !== undefined)
1121
- bag.todoIndex = legacy.todoIndex;
1122
- return;
1123
- }
1124
- }
1125
- }
1126
- function applyDefaultZoom(slots, defaultZoom) {
1127
- if (defaultZoom === undefined || !(defaultZoom > 0))
1128
- return slots;
1129
- return slots.map((h) => (h.zoom !== undefined && h.zoom > 0 ? h : { ...h, zoom: defaultZoom }));
1130
- }
1131
- function applyDefaultZoomOut(slots, defaultZoomOut) {
1132
- if (defaultZoomOut === undefined)
1133
- return slots;
1134
- return slots.map((h) => h.zoomOut !== undefined ? h : { ...h, zoomOut: defaultZoomOut });
1135
- }
1136
- function mergePhaseMaps(phaseMap, fixturePhase) {
1137
- const phaseKeys = Object.keys(phaseMap);
1138
- const fixtureKeys = Object.keys(fixturePhase || {});
1139
- const allKeys = [...new Set([...phaseKeys, ...fixtureKeys])];
1140
- const allNumeric = allKeys.length > 0 && allKeys.every((k) => Number.isFinite(Number(k)) && String(Number(k)) === k);
1141
- // Numeric stub ids (legacy "0","1") sort by number. Named stubs keep author
1142
- // insertion order - localeCompare used to reorder password/submit/username
1143
- // and advance the wrong todo index on fill.
1144
- const ordered = allNumeric
1145
- ? [...allKeys].sort((a, b) => Number(a) - Number(b))
1146
- : [...phaseKeys, ...fixtureKeys.filter((k) => !phaseKeys.includes(k))];
1147
- const resolved = [];
1148
- for (const id of ordered) {
1149
- const merged = mergeSlot(phaseMap[id], fixturePhase?.[id]);
1150
- if (merged) {
1151
- resolved.push({
1152
- ...merged,
1153
- slotId: id,
1154
- ...(merged.todo ? { todo: merged.todo } : {}),
1155
- });
1156
- }
1157
- }
1158
- return resolved;
1159
- }
1160
- function phaseFromLegacyHighlights(highlights) {
1161
- const out = {};
1162
- if (!highlights)
1163
- return out;
1164
- highlights.forEach((h, i) => {
1165
- if (h && typeof h.selector === "string" && typeof h.label === "string") {
1166
- out[String(i)] = {
1167
- selector: h.selector,
1168
- label: h.label,
1169
- ...(h.tone ? { tone: normalizeHighlightTone(h.tone) } : {}),
1170
- };
1171
- }
1172
- });
1173
- return out;
1174
- }
1175
- function readStubRaw(block, phase) {
1176
- const instr = block.instruction;
1177
- if (phase === "stubBefore")
1178
- return instr.stubBefore;
1179
- if (phase === "stubAfter")
1180
- return instr.stubAfter;
1181
- return instr.stubOnError;
1182
- }
1183
- function applyLegacyHighlightsShim(block, phase, phaseMap, out) {
1184
- if (phase !== "stubAfter" || Object.keys(phaseMap).length > 0)
1185
- return phaseMap;
1186
- const instr = block.instruction;
1187
- if (!instr.highlights)
1188
- return phaseMap;
1189
- let legacy = instr.highlights;
1190
- if (typeof legacy === "function") {
1191
- try {
1192
- legacy = legacy(out ?? { __state: "" });
1193
- }
1194
- catch {
1195
- legacy = [];
1196
- }
1197
- }
1198
- return phaseFromLegacyHighlights(Array.isArray(legacy) ? legacy : []);
1199
- }
1200
- /**
1201
- * Run an open stub lifecycle (preferred) or resolve a static slot map.
1202
- * Returns rings + episode fixtures (todos / todoIndex / zoom).
1203
- *
1204
- * @example
1205
- * const before = await runStubPhase(block, "stubBefore", { fixtures });
1206
- * // before.todos, before.highlights
1207
- */
1208
- export async function runStubPhase(block, phase, opts) {
1209
- const bag = { highlights: {} };
1210
- const ctx = createStubCtx(bag, {
1211
- ...(opts?.out !== undefined ? { out: opts.out } : {}),
1212
- ...(opts?.error !== undefined ? { error: opts.error } : {}),
1213
- ...(opts?.mem !== undefined ? { mem: opts.mem } : {}),
1214
- });
1215
- const raw = readStubRaw(block, phase);
1216
- if (typeof raw === "function") {
1217
- try {
1218
- const ret = await raw(ctx);
1219
- if (isStubPhase(ret)) {
1220
- bag.highlights = { ...bag.highlights, ...ret };
1221
- }
1222
- }
1223
- catch {
1224
- /* best-effort */
1225
- }
1226
- // Compat: legacy `(out) => map` that expected Checkpoint (__state on arg)
1227
- if (Object.keys(bag.highlights).length === 0 && !bag.todos) {
1228
- try {
1229
- const legacyOut = (opts?.out ?? { __state: "" });
1230
- const ret2 = raw(legacyOut);
1231
- if (isStubPhase(ret2))
1232
- bag.highlights = { ...ret2 };
1233
- }
1234
- catch {
1235
- /* ignore */
1236
- }
1237
- }
1238
- }
1239
- else if (isStubPhase(raw)) {
1240
- bag.highlights = { ...raw };
1241
- }
1242
- bag.highlights = applyLegacyHighlightsShim(block, phase, bag.highlights, opts?.out);
1243
- liftLegacySlotTodos(bag);
1244
- const fixturePhase = opts?.fixtures?.[block.name]?.[phase];
1245
- let highlights = mergePhaseMaps(bag.highlights, fixturePhase);
1246
- highlights = applyDefaultZoom(highlights, bag.zoom);
1247
- highlights = applyDefaultZoomOut(highlights, bag.zoomOut);
1248
- const todoDock = bag.hideTodos
1249
- ? undefined
1250
- : buildTodoDock({
1251
- todos: bag.todos,
1252
- todoIndex: bag.todoIndex,
1253
- todoTitle: bag.todoTitle,
1254
- todoId: bag.todoId,
1255
- todoStyle: bag.todoStyle,
1256
- todoGroups: bag.todoGroups,
1257
- todoPos: bag.todoPos,
1258
- });
1259
- const todos = todoDock?.groups[0]?.items ||
1260
- (bag.hideTodos
1261
- ? []
1262
- : normalizeTodos(bag.todos, bag.todoIndex, normalizeTodoStyle(bag.todoStyle) || "sequential"));
1263
- // Omit todos entirely => keep previous dock. Explicit empty / hideTodos => clear.
1264
- const todoSync = bag.hideTodos
1265
- ? "clear"
1266
- : bag.todoTouched && todoDock
1267
- ? "set"
1268
- : bag.todoTouched && !todoDock
1269
- ? "clear"
1270
- : "keep";
1271
- let deviceState;
1272
- if (bag.hideDevice) {
1273
- deviceState = resolveDeviceState("desktop", false);
1274
- }
1275
- else if (bag.device) {
1276
- deviceState = bag.touchOverride !== undefined
1277
- ? {
1278
- ...bag.device,
1279
- touchMode: bag.touchOverride,
1280
- hasTouch: bag.device.hasTouch || bag.touchOverride,
1281
- }
1282
- : bag.device;
1283
- }
1284
- else if (bag.touchOverride !== undefined) {
1285
- deviceState = resolveDeviceState("desktop", bag.touchOverride);
1286
- }
1287
- const deviceSync = bag.hideDevice
1288
- ? "clear"
1289
- : bag.deviceTouched && deviceState
1290
- ? "set"
1291
- : bag.deviceTouched && !deviceState
1292
- ? "clear"
1293
- : "keep";
1294
- return {
1295
- highlights,
1296
- todos,
1297
- todoSync,
1298
- deviceSync,
1299
- ...(bag.todoIndex !== undefined ? { todoIndex: bag.todoIndex } : {}),
1300
- ...(todoDock ? { todoDock } : {}),
1301
- ...(deviceState ? { device: deviceState } : {}),
1302
- ...(bag.zoom !== undefined ? { zoom: bag.zoom } : {}),
1303
- ...(bag.zoomOut !== undefined ? { zoomOut: bag.zoomOut } : {}),
1304
- ...(bag.title !== undefined && bag.title !== "" ? { title: bag.title } : {}),
1305
- ...(bag.todoPos !== undefined ? { todoPos: bag.todoPos } : {}),
1306
- ...(bag.todoDockUi !== undefined ? { todoDockUi: bag.todoDockUi } : {}),
1307
- ...(bag.todoParallel === true ? { todoParallel: true } : {}),
1308
- };
1309
- }
1310
- /**
1311
- * Sync resolve of highlight slots. Prefer {@link runStubPhase} for open
1312
- * async lifecycles + episode fixtures (todos/zoom).
1313
- */
1314
- export function resolveHighlightSlots(block, phase, opts) {
1315
- const bag = { highlights: {} };
1316
- const raw = readStubRaw(block, phase);
1317
- if (typeof raw === "function") {
1318
- try {
1319
- const ctx = createStubCtx(bag, {
1320
- ...(opts?.out !== undefined ? { out: opts.out } : {}),
1321
- });
1322
- const ret = raw(ctx);
1323
- if (isStubPhase(ret)) {
1324
- bag.highlights = { ...bag.highlights, ...ret };
1325
- }
1326
- }
1327
- catch {
1328
- try {
1329
- const legacyOut = (opts?.out ?? { __state: "" });
1330
- const ret2 = raw(legacyOut);
1331
- if (isStubPhase(ret2))
1332
- bag.highlights = { ...ret2 };
1333
- }
1334
- catch {
1335
- bag.highlights = {};
1336
- }
1337
- }
1338
- }
1339
- else if (isStubPhase(raw)) {
1340
- bag.highlights = { ...raw };
1341
- }
1342
- bag.highlights = applyLegacyHighlightsShim(block, phase, bag.highlights, opts?.out);
1343
- liftLegacySlotTodos(bag);
1344
- const fixturePhase = opts?.fixtures?.[block.name]?.[phase];
1345
- return applyDefaultZoom(mergePhaseMaps(bag.highlights, fixturePhase), bag.zoom);
1346
- }
1347
- /**
1348
- * Resolve multi-step yap slides: flow fixture `slides` wins when present,
1349
- * else `instruction.slides` (function form allowed).
1350
- */
1351
- export function resolveSlides(block, opts) {
1352
- const fromFixture = opts?.fixtures?.[block.name]?.slides;
1353
- if (fromFixture && fromFixture.length > 0) {
1354
- return [...fromFixture];
1355
- }
1356
- const instr = block.instruction;
1357
- let raw = instr.slides;
1358
- if (typeof raw === "function") {
1359
- try {
1360
- raw = raw(opts?.out ?? { __state: "" });
1361
- }
1362
- catch {
1363
- raw = [];
1364
- }
1365
- }
1366
- if (!Array.isArray(raw))
1367
- return [];
1368
- return raw.filter((s) => s && typeof s.caption === "string" && s.caption.length > 0);
1369
- }
1370
- /** Caption for ring / slide UI (label|caption + optional detail/tag + tone icon). */
1371
- export function formatHighlightCaption(h) {
1372
- const tone = normalizeHighlightTone(h.tone);
1373
- const icon = toneIconPrefix(tone);
1374
- const primary = (h.label ?? h.caption ?? "").trim();
1375
- const tag = h.tag ? `[${h.tag}] ` : "";
1376
- if (h.detail)
1377
- return `${icon}${tag}${primary} - ${h.detail}`;
1378
- return `${icon}${tag}${primary}`;
1379
- }
1380
- function stubPhaseLooksAuthored(block, phase, fixtures) {
1381
- const raw = readStubRaw(block, phase);
1382
- if (typeof raw === "function")
1383
- return true;
1384
- if (isStubPhase(raw) && Object.keys(raw).length > 0)
1385
- return true;
1386
- const fx = fixtures?.[block.name]?.[phase];
1387
- if (fx && Object.keys(fx).length > 0)
1388
- return true;
1389
- return false;
1390
- }
1391
- /**
1392
- * True when the block authored stubAfter (lifecycle fn, slots, fixtures, or legacy highlights).
1393
- * When true, demo should not use verify-trait fallback.
1394
- */
1395
- export function hasAuthoredStubAfter(block, out, fixtures) {
1396
- if (stubPhaseLooksAuthored(block, "stubAfter", fixtures))
1397
- return true;
1398
- const instr = block.instruction;
1399
- if (instr.highlights)
1400
- return true;
1401
- return resolveHighlightSlots(block, "stubAfter", {
1402
- ...(out !== undefined ? { out } : {}),
1403
- ...(fixtures !== undefined ? { fixtures } : {}),
1404
- }).length > 0;
1405
- }
1406
- /**
1407
- * True when stubOnError is authored (lifecycle fn and/or rings).
1408
- * Demo fail path only - success never consults this.
1409
- */
1410
- export function hasAuthoredStubOnError(block, fixtures) {
1411
- if (stubPhaseLooksAuthored(block, "stubOnError", fixtures))
1412
- return true;
1413
- return (resolveHighlightSlots(block, "stubOnError", {
1414
- ...(fixtures !== undefined ? { fixtures } : {}),
1415
- }).length > 0);
1416
- }
1
+ // Barrel: highlights live in src/highlights/ (see src/ARCHITECTURE.md). Kept so existing imports of "./highlights.js" don't change.
2
+ export * from "./highlights/types.js";
3
+ export * from "./highlights/todo-dock.js";
4
+ export * from "./highlights/device.js";
5
+ export * from "./highlights/css.js";
6
+ export * from "./highlights/style.js";
7
+ export * from "./highlights/demo-pace.js";
8
+ export * from "./highlights/merge.js";
9
+ export * from "./highlights/stub-ctx.js";
10
+ export * from "./highlights/run-phase.js";