@celestia-island/hikari 0.55.47 → 0.55.49

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,32 +1,9 @@
1
1
  import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import { createApp, defineComponent, h, nextTick, ref } from "vue";
3
3
 
4
- import { readHkRuntime } from "../runtime/registry";
5
- import HkStepFlow, {
6
- SHEET_SWEEP_SETTLE_EVENT,
7
- SHEET_SWEEP_STAGE_EVENT,
8
- STEPFLOW_SWAP_EVENT,
9
- } from "./HkStepFlow";
4
+ import HkStepFlow, { STEPFLOW_SWAP_EVENT } from "./HkStepFlow";
10
5
  import type { StepFlowSlotProps } from "./HkStepFlow";
11
6
 
12
- /**
13
- * HkStepFlow contract tests. House style: no @vue/test-utils dependency —
14
- * raw createApp mounts on shared containers torn down after each case.
15
- *
16
- * Two environment shapes are exercised:
17
- *
18
- * - DEFAULT (happy-dom, no stylesheet): the component's duration probe
19
- * reads a zero transition duration, so every swap settles INSTANTLY —
20
- * one body in the DOM, no timers to outlive, no transitionend to wait
21
- * for. This is the deterministic shape chest's wizard tests rely on.
22
- * - MOTION STUBBED (`getComputedStyle` reports the PHASE duration): the
23
- * two-phase choreography runs — the exit phase keeps only the old body
24
- * visible (`hk-stepflow-leave-to`) while the new one is mounted but
25
- * `hk-stepflow-enter-pending`, then the enter phase drops the old body
26
- * and fades the new one in place. The phases advance on the acting
27
- * body's own transitionend or the duration+grace watchdog.
28
- */
29
-
30
7
  const mounts: ReturnType<typeof createApp>[] = [];
31
8
  const containers: HTMLElement[] = [];
32
9
 
@@ -48,40 +25,9 @@ function mountStepFlow(options: {
48
25
  timelineClickable?: boolean;
49
26
  collapse?: string;
50
27
  seen?: StepFlowSlotProps[];
51
- /** Mount inside a modal body host (HkModal's scroll container), i.e. the
52
- * host shape whose sheet publishes sweep spans. */
53
- sheetHost?: boolean;
54
- /** Span the simulated sheet publishes when it stages a fold. */
55
- sweepSpan?: { from: number; to: number; sweep?: number } | null;
56
28
  } = {}): StepFlowHarness {
57
29
  const container = document.createElement("div");
58
- if (options.sheetHost) {
59
- const body = document.createElement("div");
60
- body.className = "hk-modal-body";
61
- body.appendChild(container);
62
- document.body.appendChild(body);
63
- containers.push(body);
64
- // Stand in for the hosting modal: when the flow announces the enter
65
- // edge of a shrink, publish the fold span the sheet would publish.
66
- if (options.sweepSpan) {
67
- const span = options.sweepSpan;
68
- container.addEventListener(STEPFLOW_SWAP_EVENT, (e) => {
69
- if ((e as CustomEvent).detail?.phase !== "enter") return;
70
- body.dispatchEvent(
71
- new CustomEvent(SHEET_SWEEP_STAGE_EVENT, {
72
- detail: {
73
- direction: "conceal",
74
- from: span.from,
75
- to: span.to,
76
- sweep: span.sweep ?? 1,
77
- },
78
- }),
79
- );
80
- });
81
- }
82
- } else {
83
- document.body.appendChild(container);
84
- }
30
+ document.body.appendChild(container);
85
31
  containers.push(container);
86
32
 
87
33
  const current = ref(options.initial ?? "a");
@@ -112,26 +58,16 @@ function mountStepFlow(options: {
112
58
  return { container, setCurrent: (key) => { current.value = key; } };
113
59
  }
114
60
 
115
- /** Let the swap watch's async flush (post render + internal nextTick) land. */
116
61
  async function flushSwap(): Promise<void> {
117
62
  await nextTick();
118
63
  await nextTick();
119
64
  await nextTick();
120
65
  }
121
66
 
122
- /** Wait past the watchdog of ONE phase (duration + 350ms grace). */
123
67
  async function outlivePhase(): Promise<void> {
124
68
  await new Promise((resolve) => setTimeout(resolve, 430));
125
69
  }
126
70
 
127
- /** Outlive BOTH phases' watchdogs. */
128
- async function outliveSwap(): Promise<void> {
129
- await new Promise((resolve) => setTimeout(resolve, 900));
130
- }
131
-
132
- /** Force the motion path: the duration probe sees a phase-length transition.
133
- * `leavingOpacity` models how far an exit has run (the pre-emption fast
134
- * path keys off a body that has already faded to nothing). */
135
71
  function stubMotion(
136
72
  duration = "0.15s",
137
73
  opts: { leavingOpacity?: string } = {},
@@ -151,14 +87,9 @@ function stubMotion(
151
87
  return real(el, pseudoElt ?? undefined);
152
88
  },
153
89
  );
154
- return (next: string) => {
155
- current = next;
156
- };
90
+ return (next: string) => { current = next; };
157
91
  }
158
92
 
159
- /** happy-dom reports every box as 0px tall and never transitions, so the
160
- * choreography's grow/shrink branches and its phase edges are driven here
161
- * explicitly: heights by body text, edges by synthesized transitionend. */
162
93
  const realOffsetHeight = Object.getOwnPropertyDescriptor(
163
94
  HTMLElement.prototype,
164
95
  "offsetHeight",
@@ -168,16 +99,11 @@ function stubHeights(byText: (text: string) => number): void {
168
99
  Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
169
100
  configurable: true,
170
101
  get(this: HTMLElement) {
171
- // The stage's height is its in-flow body's (the leaving one is out of
172
- // flow) held up by its own pin, exactly as the browser resolves it —
173
- // the component reads it as the swap's "old" reference.
174
102
  if (this.classList?.contains("hk-stepflow-bodies")) {
175
103
  const inFlow = Array.from(this.children).find(
176
104
  (child) => !(child as HTMLElement).classList.contains("leaving"),
177
105
  ) as HTMLElement | undefined;
178
- const flowH = inFlow ? byText(inFlow.textContent ?? "") : 0;
179
- const pinned = Number.parseFloat(this.style.minHeight) || 0;
180
- return Math.max(pinned, flowH);
106
+ return inFlow ? byText(inFlow.textContent ?? "") : 0;
181
107
  }
182
108
  return this.classList?.contains("hk-stepflow-body")
183
109
  ? byText(this.textContent ?? "")
@@ -192,7 +118,6 @@ function restoreHeights(): void {
192
118
  }
193
119
  }
194
120
 
195
- /** Fire the transitionend a real engine would send for this element. */
196
121
  function endTransition(el: Element | null | undefined): void {
197
122
  if (!el) throw new Error("endTransition: element missing");
198
123
  el.dispatchEvent(new Event("transitionend", { bubbles: true }));
@@ -208,30 +133,14 @@ afterEach(() => {
208
133
  describe("HkStepFlow", () => {
209
134
  it("renders the timeline header bound to modelValue plus the slotted body", () => {
210
135
  const t = mountStepFlow({ initial: "b" });
211
- const header = t.container.querySelector(".hk-timeline");
212
- expect(header).not.toBeNull();
213
- expect(header?.querySelector('[data-el="label"]')?.textContent).toBe("Alpha");
214
- expect(
215
- t.container.querySelector(".hk-timeline-step[aria-current='step'] [data-el='label']")
216
- ?.textContent,
217
- ).toBe("Beta");
136
+ expect(t.container.querySelector(".hk-timeline")).not.toBeNull();
218
137
  expect(t.container.querySelector(".hk-stepflow-body")?.textContent).toBe("b-body");
219
138
  });
220
139
 
221
- it("hides the timeline header with hideTimeline while keeping the body", () => {
222
- const t = mountStepFlow({ hideTimeline: true });
223
- expect(t.container.querySelector(".hk-timeline")).toBeNull();
224
- expect(t.container.querySelector(".hk-step-flow")).not.toBeNull();
225
- expect(t.container.querySelector(".hk-stepflow-body")?.textContent).toBe("a-body");
226
- });
227
-
228
- it("swaps the named-slot content per step key", async () => {
140
+ it("swaps the named-slot content per step key (instant path)", async () => {
229
141
  const t = mountStepFlow({ initial: "a" });
230
- expect(t.container.querySelector(".hk-stepflow-body")?.textContent).toBe("a-body");
231
142
  t.setCurrent("d");
232
143
  await flushSwap();
233
- // Stylesheet-less runtime → instant settle: exactly one body, the new
234
- // step's, with no leaving residue and no timers to outlive.
235
144
  const bodies = t.container.querySelectorAll(".hk-stepflow-body");
236
145
  expect(bodies.length).toBe(1);
237
146
  expect(bodies[0]!.className).toBe("hk-stepflow-body active");
@@ -239,10 +148,6 @@ describe("HkStepFlow", () => {
239
148
  });
240
149
 
241
150
  it("settles deterministically to a single body where no transition runs", async () => {
242
- // The 2026-09-22 debt pin: chest's LoginView MFA suite broke because
243
- // the crossfade kept a leaving body alive for hundreds of ms in
244
- // transitionend-less environments. With a zero probed duration the
245
- // swap is atomic — the DOM NEVER carries two steps.
246
151
  const t = mountStepFlow({ initial: "a" });
247
152
  const events: CustomEvent[] = [];
248
153
  t.container.addEventListener(STEPFLOW_SWAP_EVENT, (e) => {
@@ -251,16 +156,11 @@ describe("HkStepFlow", () => {
251
156
  t.setCurrent("b");
252
157
  await flushSwap();
253
158
  expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(1);
254
- expect(t.container.querySelector(".hk-stepflow-body.active")?.textContent).toBe("b-body");
255
- expect(t.container.querySelector(".hk-stepflow-body.leaving")).toBeNull();
256
- // Rapid successive swaps stay single-bodied the whole way through.
257
159
  t.setCurrent("c");
258
160
  await flushSwap();
259
161
  t.setCurrent("d");
260
162
  await flushSwap();
261
163
  expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(1);
262
- expect(t.container.querySelector(".hk-stepflow-body.active")?.textContent).toBe("d-body");
263
- // The sheet passthrough fired for every swap, carrying a numeric delta.
264
164
  expect(events.length).toBe(3);
265
165
  for (const e of events) {
266
166
  expect(typeof (e.detail as { delta: number }).delta).toBe("number");
@@ -270,156 +170,32 @@ describe("HkStepFlow", () => {
270
170
  it("passes key/index/direction to scoped slots across navigation", async () => {
271
171
  const seen: StepFlowSlotProps[] = [];
272
172
  const t = mountStepFlow({ initial: "a", seen });
273
- expect(seen.map((s) => `${s.key}:${s.index}:${s.direction}`)).toEqual(["a:0:forward"]);
274
-
275
173
  t.setCurrent("c");
276
174
  await flushSwap();
277
175
  expect(seen.at(-1)).toEqual({ key: "c", index: 2, direction: "forward" });
278
-
279
176
  t.setCurrent("b");
280
177
  await flushSwap();
281
178
  expect(seen.at(-1)).toEqual({ key: "b", index: 1, direction: "back" });
282
-
283
- // Returning forward again flips the direction back.
284
- t.setCurrent("d");
285
- await flushSwap();
286
- expect(seen.at(-1)?.direction).toBe("forward");
287
- });
288
-
289
- it("emits update:modelValue when a clickable completed step is selected", async () => {
290
- const selected: string[] = [];
291
- const container = document.createElement("div");
292
- document.body.appendChild(container);
293
- containers.push(container);
294
-
295
- const current = ref("c");
296
- const app = createApp(defineComponent({
297
- setup() {
298
- return () =>
299
- h(HkStepFlow, {
300
- steps: STEPS,
301
- modelValue: current.value,
302
- timelineClickable: true,
303
- "onUpdate:modelValue": (key: string) => { selected.push(key); current.value = key; },
304
- }, {
305
- c: () => h("p", "C-body"),
306
- b: () => h("p", "B-body"),
307
- });
308
- },
309
- }));
310
- mounts.push(app);
311
- app.mount(container);
312
-
313
- // Full-row mode with plenty of room: click the first completed step.
314
- const first = container.querySelector(".hk-timeline-step[data-clickable]") as HTMLElement;
315
- expect(first?.getAttribute("data-status")).toBe("completed");
316
- first.dispatchEvent(new MouseEvent("click"));
317
- await nextTick();
318
- await flushSwap();
319
- expect(selected).toEqual(["a"]);
320
- expect(container.querySelector(".hk-stepflow-body.active")?.textContent).toBe("");
321
- });
322
-
323
- it("renders an empty body without warnings for an unknown key", async () => {
324
- const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
325
- try {
326
- const t = mountStepFlow({ initial: "a" });
327
- t.setCurrent("does-not-exist");
328
- await nextTick();
329
- await flushSwap();
330
- const body = t.container.querySelector(".hk-stepflow-body.active");
331
- expect(body).not.toBeNull();
332
- expect(body?.textContent).toBe("");
333
- expect(warnSpy.mock.calls.some((args) => String(args[0]).includes("Slot"))).toBe(false);
334
- } finally {
335
- warnSpy.mockRestore();
336
- }
337
179
  });
338
180
 
339
181
  it("pins the collapse option onto the header timeline", () => {
340
182
  const t = mountStepFlow({ collapse: "always" });
341
- // Four steps with collapse=always must render the window (compact) form.
342
183
  expect(t.container.querySelector(".hk-timeline")?.getAttribute("data-mode")).toBe("window");
343
- const bare = mountStepFlow({ collapse: "never" });
344
- expect(bare.container.querySelector(".hk-timeline")?.getAttribute("data-mode")).toBe("full");
345
- });
346
-
347
- it("resyncs the direction memory when the steps array is swapped in place", async () => {
348
- const seen: StepFlowSlotProps[] = [];
349
- const container = document.createElement("div");
350
- document.body.appendChild(container);
351
- containers.push(container);
352
-
353
- const current = ref("a");
354
- const steps = ref(STEPS.map((s) => ({ ...s })));
355
- const app = createApp(defineComponent({
356
- setup() {
357
- return () =>
358
- h(HkStepFlow, {
359
- steps: steps.value,
360
- modelValue: current.value,
361
- "onUpdate:modelValue": (key: string) => { current.value = key; },
362
- }, Object.fromEntries(steps.value.map((s) => [
363
- s.key,
364
- (arg: StepFlowSlotProps) => { seen.push(arg); return h("p", `${s.key}-body`); },
365
- ])));
366
- },
367
- }));
368
- mounts.push(app);
369
- app.mount(container);
370
-
371
- // Swap the array (same current key) — e.g. a locale switch rebuilding it.
372
- steps.value = [...steps.value].reverse().map((s) => ({ ...s }));
373
- await nextTick();
374
-
375
- // Discriminating case: WITHOUT the resync watch the remembered index
376
- // stays 0 ("a"'s pre-swap slot); landing on "c" (now index 1) would then
377
- // compare 1 < 0 and mislabel the move FORWARD. With the resync the
378
- // remembered index follows the array to 3, so 1 < 3 correctly reads BACK.
379
- current.value = "c";
380
- await nextTick();
381
- await flushSwap();
382
- expect(seen.at(-1)).toEqual({ key: "c", index: 1, direction: "back" });
383
184
  });
384
185
  });
385
186
 
386
- // ── Motion-stubbed two-phase choreography ─────────────────────────────
387
- // `getComputedStyle` reports the PHASE duration for step bodies, so the
388
- // full two-phase swap runs: the exit phase keeps only the old body
389
- // visible (sliding out) with the new one mounted but invisible, then the
390
- // enter phase drops the old body and fades the new one in place. Phases
391
- // advance on the acting body's transitionend or the watchdog.
392
- describe("HkStepFlow two-phase swap (motion enabled)", () => {
393
- it("keeps the new body invisible through the exit phase", async () => {
187
+ describe("HkStepFlow simplified swap (motion enabled)", () => {
188
+ it("shows only the old body through the exit phase, then fades the new one in", async () => {
394
189
  stubMotion();
395
190
  const t = mountStepFlow({ initial: "a" });
396
- const events: CustomEvent[] = [];
397
- t.container.addEventListener(STEPFLOW_SWAP_EVENT, (e) => {
398
- events.push(e as CustomEvent);
399
- });
400
191
  t.setCurrent("b");
401
192
  await flushSwap();
402
193
 
403
194
  const leaving = t.container.querySelector<HTMLElement>(".hk-stepflow-body.leaving");
404
195
  const pending = t.container.querySelector<HTMLElement>(".hk-stepflow-body.active");
405
- expect(leaving?.textContent).toBe("a-body");
406
- expect(pending?.textContent).toBe("b-body");
407
- // The old body carries its end state from the swap patch itself; the
408
- // new one is mounted (so the flow owns the new height) but staged
409
- // invisible — the two can never be on screen together.
410
196
  expect(leaving?.classList.contains("hk-stepflow-leave-to")).toBe(true);
411
197
  expect(pending?.classList.contains("hk-stepflow-enter-pending")).toBe(true);
412
- expect(t.container.querySelector(".hk-stepflow-enter-from")).toBeNull();
413
- expect(
414
- t.container.querySelector(".hk-stepflow-bodies")?.getAttribute("data-direction"),
415
- ).toBe("forward");
416
- // happy-dom heights read 0 → the GROW edge: the sheet hears about the
417
- // new geometry while the old body is still leaving.
418
- expect(events.length).toBe(1);
419
- expect((events[0]!.detail as { phase: string }).phase).toBe("exit");
420
- expect((events[0]!.detail as { durationMs: number }).durationMs).toBe(150);
421
198
 
422
- // Enter edge: the old body goes, the new one is revealed in place.
423
199
  endTransition(leaving);
424
200
  await flushSwap();
425
201
  expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(1);
@@ -427,16 +203,14 @@ describe("HkStepFlow two-phase swap (motion enabled)", () => {
427
203
  expect(active?.textContent).toBe("b-body");
428
204
  expect(active?.classList.contains("hk-stepflow-enter-pending")).toBe(false);
429
205
 
430
- // The enter phase's own end settles the swap; a grow announces once.
431
206
  endTransition(active);
432
207
  await flushSwap();
433
- expect(events.length).toBe(1);
434
208
  expect(t.container.querySelector(".hk-stepflow-body")?.className).toBe(
435
209
  "hk-stepflow-body active",
436
210
  );
437
211
  });
438
212
 
439
- it("finishes a GROW through the exit phase and never pins the flow", async () => {
213
+ it("announces a GROW at the exit edge and a SHRINK at the enter edge", async () => {
440
214
  stubMotion();
441
215
  stubHeights((text) => (text === "a-body" ? 100 : 220));
442
216
  const t = mountStepFlow({ initial: "a" });
@@ -447,597 +221,23 @@ describe("HkStepFlow two-phase swap (motion enabled)", () => {
447
221
  t.setCurrent("b");
448
222
  await flushSwap();
449
223
  expect(events.length).toBe(1);
224
+ expect((events[0]!.detail as { phase: string }).phase).toBe("exit");
450
225
  expect((events[0]!.detail as { delta: number }).delta).toBe(120);
451
- const stage = t.container.querySelector<HTMLElement>(".hk-stepflow-bodies");
452
- expect(stage?.style.minHeight).toBe("");
453
- // An in-flow stage (a non-modal host such as chest's LoginView) keeps
454
- // the default TOP anchor: its top line is the fixed one, so a bottom
455
- // anchor would push the old body down instead of holding it.
456
- expect(stage?.getAttribute("data-anchor")).toBeNull();
457
- // A grow never parks the new body in the tail band and never pins: the
458
- // flow owns the new height from frame one.
459
- expect(
460
- t.container
461
- .querySelector<HTMLElement>(".hk-stepflow-body.active")
462
- ?.classList.contains("hk-stepflow-enter-tail"),
463
- ).toBe(false);
464
- endTransition(t.container.querySelector(".hk-stepflow-body.leaving"));
465
- await flushSwap();
466
- // No second announcement: the sheet already owns the new geometry.
467
- expect(events.length).toBe(1);
468
- });
469
-
470
- it("holds a SHRINK, then parks the new body on the line the sheet lands on", async () => {
471
- stubMotion();
472
- stubHeights((text) => (text === "a-body" ? 220 : 100));
473
- const t = mountStepFlow({
474
- initial: "a",
475
- sheetHost: true,
476
- sweepSpan: { from: 220, to: 100 },
477
- });
478
- const events: CustomEvent[] = [];
479
- // What the hosting sheet would measure when it handles the announce:
480
- // the pin must be OFF at that instant (the sheet reads the frame's NEW
481
- // natural height) and back ON immediately after.
482
- const pinAtAnnounce: string[] = [];
483
- t.container.addEventListener(STEPFLOW_SWAP_EVENT, (e) => {
484
- events.push(e as CustomEvent);
485
- pinAtAnnounce.push(
486
- (t.container.querySelector<HTMLElement>(".hk-stepflow-bodies")?.style
487
- .minHeight) ?? "missing",
488
- );
489
- });
490
- t.setCurrent("b");
491
- await flushSwap();
492
- // Exit phase: nothing morphs yet — the old body plays at its own
493
- // height (the flow is pinned so the sheet cannot fold under it).
494
- expect(events.length).toBe(0);
495
- const bodies = t.container.querySelector<HTMLElement>(".hk-stepflow-bodies");
496
- expect(bodies?.style.minHeight).toBe("220px");
497
-
498
- // Enter edge: the sheet is told to fold, publishes its real span, and
499
- // the flow parks the new body exactly on the landing line.
226
+ // Drive the first swap to completion before starting the second.
500
227
  endTransition(t.container.querySelector(".hk-stepflow-body.leaving"));
501
228
  await flushSwap();
502
- expect(events.length).toBe(1);
503
- const detail = events[0]!.detail as {
504
- delta: number;
505
- durationMs: number;
506
- phase: string;
507
- };
508
- expect(detail.delta).toBe(-120);
509
- expect(detail.phase).toBe("enter");
510
- expect(detail.durationMs).toBe(150);
511
- expect(pinAtAnnounce).toEqual([""]);
512
- expect(bodies?.style.minHeight).toBe("220px");
513
- const active = t.container.querySelector<HTMLElement>(".hk-stepflow-body.active");
514
- expect(active?.textContent).toBe("b-body");
515
- expect(active?.classList.contains("hk-stepflow-enter-pending")).toBe(false);
516
- expect(active?.classList.contains("hk-stepflow-enter-tail")).toBe(true);
517
- expect(active?.style.top).toBe("120px");
518
-
519
- // The sheet's landing releases the parking and the pin together — not
520
- // the body's own fade (which would leave the frame clipped).
521
- t.container
522
- .closest(".hk-modal-body")!
523
- .dispatchEvent(new CustomEvent(SHEET_SWEEP_SETTLE_EVENT));
524
- await flushSwap();
525
- expect(bodies?.style.minHeight).toBe("");
526
- const settled = t.container.querySelector<HTMLElement>(".hk-stepflow-body.active");
527
- expect(settled?.classList.contains("hk-stepflow-enter-tail")).toBe(false);
528
- expect(settled?.style.top).toBe("");
529
- });
530
-
531
- it("does not park anything when the sheet reports a zero span (capped box)", async () => {
532
- stubMotion();
533
- stubHeights((text) => (text === "a-body" ? 220 : 100));
534
- // A capped sheet cannot fold: it publishes from == to. Parking against
535
- // a guessed delta instead cut 276px off the outgoing step (R2 finding).
536
- const t = mountStepFlow({
537
- initial: "a",
538
- sheetHost: true,
539
- sweepSpan: { from: 220, to: 220 },
540
- });
541
- const events: CustomEvent[] = [];
542
- t.container.addEventListener(STEPFLOW_SWAP_EVENT, (e) => {
543
- events.push(e as CustomEvent);
544
- });
545
- t.setCurrent("b");
546
- await flushSwap();
547
- endTransition(t.container.querySelector(".hk-stepflow-body.leaving"));
548
- await flushSwap();
549
- expect(events.length).toBe(1);
550
- const bodies = t.container.querySelector<HTMLElement>(".hk-stepflow-bodies");
551
- // Nothing is held: the stage takes the new height immediately and the
552
- // new body fades in in place.
553
- expect(bodies?.style.minHeight).toBe("");
554
- const active = t.container.querySelector<HTMLElement>(".hk-stepflow-body.active");
555
- expect(active?.classList.contains("hk-stepflow-enter-tail")).toBe(false);
556
- expect(active?.style.top ?? "").toBe("");
557
- });
558
-
559
- it("releases a parked fold on the watchdog when the sheet never lands", async () => {
560
- // The enter-phase watchdog is load-bearing: a host that never publishes
561
- // its landing must not strand the pin and the parked body forever
562
- // (removing it survived the suite before this test existed).
563
- stubMotion("20ms");
564
- stubHeights((text) => (text === "a-body" ? 220 : 100));
565
- const t = mountStepFlow({
566
- initial: "a",
567
- sheetHost: true,
568
- sweepSpan: { from: 220, to: 100 },
569
- });
570
- t.setCurrent("b");
571
- await flushSwap();
572
- endTransition(t.container.querySelector(".hk-stepflow-body.leaving"));
573
- await flushSwap();
574
- const bodies = t.container.querySelector<HTMLElement>(".hk-stepflow-bodies");
575
- expect(bodies?.style.minHeight).toBe("220px");
576
- expect(
577
- t.container
578
- .querySelector<HTMLElement>(".hk-stepflow-body.active")
579
- ?.classList.contains("hk-stepflow-enter-tail"),
580
- ).toBe(true);
581
- // 20ms phase + 700ms tail grace, with no settle event at all.
582
- await new Promise((resolve) => setTimeout(resolve, 800));
583
- expect(bodies?.style.minHeight).toBe("");
584
- const settled = t.container.querySelector<HTMLElement>(".hk-stepflow-body.active");
585
- expect(settled?.classList.contains("hk-stepflow-enter-tail")).toBe(false);
586
- expect(settled?.style.top).toBe("");
587
- });
588
-
589
- it("ignores a stale phase watchdog from the previous swap", async () => {
590
- // endSwap must disarm the outgoing phase: a stale watchdog otherwise
591
- // tears the NEXT swap down 184ms early and clips its content (the
592
- // mutation that removes disarmPhase survived the suite before this).
593
- stubMotion("20ms");
594
- const t = mountStepFlow({ initial: "a" });
595
- t.setCurrent("b");
596
- await flushSwap();
597
- endTransition(t.container.querySelector(".hk-stepflow-body.leaving"));
598
- await flushSwap();
599
- endTransition(t.container.querySelector(".hk-stepflow-body.active"));
600
- await flushSwap();
601
- expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(1);
602
-
603
- // Let most of the settled swap's watchdog window pass, THEN start a
604
- // fresh swap: its own deadline lands at ~300ms + 370ms, while the
605
- // first swap's (now stale) timer is due at ~370ms — so a missing
606
- // disarm would advance the live swap roughly 300ms early.
607
- await new Promise((resolve) => setTimeout(resolve, 300));
608
- t.setCurrent("c");
609
- await flushSwap();
610
- expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(2);
611
- await new Promise((resolve) => setTimeout(resolve, 120));
612
- // The stale closer must not have ended the live swap early.
613
- const leaving = t.container.querySelector<HTMLElement>(".hk-stepflow-body.leaving");
614
- expect(leaving?.textContent).toBe("b-body");
615
- expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(2);
616
- endTransition(leaving);
617
- await flushSwap();
618
229
  endTransition(t.container.querySelector(".hk-stepflow-body.active"));
619
230
  await flushSwap();
620
- expect(t.container.querySelector(".hk-stepflow-body")?.textContent).toBe("c-body");
621
- });
622
-
623
- it("keeps the outgoing body on its pre-swap screen line by measurement", async () => {
624
- // The host decides where a growing box's lines sit, so the shift is
625
- // measured: this stubs the two rect reads the component performs (the
626
- // body before the patch and after it) and pins the inline offset.
627
- stubMotion();
628
- const rects = { active: 500, leaving: 380 };
629
- const proto = HTMLElement.prototype as unknown as {
630
- getBoundingClientRect: () => DOMRect;
631
- };
632
- const realRect = proto.getBoundingClientRect;
633
- proto.getBoundingClientRect = function (this: HTMLElement): DOMRect {
634
- const top = this.classList.contains("hk-stepflow-body")
635
- ? this.classList.contains("leaving")
636
- ? rects.leaving
637
- : rects.active
638
- : 0;
639
- return { top, left: 0, bottom: top, right: 0, width: 0, height: 0, x: 0, y: top, toJSON: () => ({}) } as DOMRect;
640
- };
641
- try {
642
- const t = mountStepFlow({ initial: "a" });
643
- t.setCurrent("b");
644
- await flushSwap();
645
- const leaving = t.container.querySelector<HTMLElement>(".hk-stepflow-body.leaving");
646
- expect(leaving?.style.top).toBe("120px");
647
- } finally {
648
- proto.getBoundingClientRect = realRect;
649
- }
650
- });
651
-
652
- it("never empties the queue when a pre-emption lands inside the fast-path frame", async () => {
653
- // The skipExit fast path drops its outgoing body and commits the new
654
- // one's staged state on the next bus frame. A navigation INSIDE that
655
- // frame used to re-enter the exit branch, drop the only remaining body
656
- // and leave the queue permanently empty (R3 audit finding L1). The
657
- // phase now flips to "enter" immediately and preemptSwap falls back to
658
- // endSwap when the body it names is already gone.
659
- stubMotion("0.15s", { leavingOpacity: "0" });
660
- const frames: FrameRequestCallback[] = [];
661
- vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
662
- frames.push(cb);
663
- return frames.length;
664
- });
665
- vi.stubGlobal("cancelAnimationFrame", () => {});
666
-
667
- const t = mountStepFlow({ initial: "a" });
668
- t.setCurrent("b");
669
- await flushSwap();
670
- // b is pending; swap again: the leaving body reads as fully faded, so
671
- // the new swap takes the fast path and queues its staging frame.
672
- t.setCurrent("c");
673
- await flushSwap();
674
231
  expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(1);
675
- expect(t.container.querySelector(".hk-stepflow-body.active")?.textContent).toBe("c-body");
676
232
 
677
- // A change INSIDE that frame must not empty the queue…
678
- t.setCurrent("d");
679
- await flushSwap();
680
- expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBeGreaterThan(0);
681
- const active = t.container.querySelector<HTMLElement>(".hk-stepflow-body.active");
682
- expect(active?.textContent).toBe("d-body");
683
- // …and the swap still settles once the queued bus frame runs.
684
- for (const cb of frames.splice(0)) cb(0);
685
- await flushSwap();
686
- endTransition(t.container.querySelector(".hk-stepflow-body.leaving"));
687
- await flushSwap();
688
- endTransition(t.container.querySelector(".hk-stepflow-body.active"));
689
- await flushSwap();
690
- // Still navigable afterwards (the failure mode was permanent blankness).
691
233
  t.setCurrent("a");
692
234
  await flushSwap();
693
- expect(t.container.querySelector(".hk-stepflow-body.active")?.textContent).toBe("a-body");
694
- });
695
-
696
- it("compensates the shift the sheet causes when it lands the new height", async () => {
697
- // The stage only moves when the HOST reacts to the handoff (a
698
- // bottom-docked sheet pins the new height the moment it is told), so
699
- // the shift must be measured AFTER the announce. Measuring before it
700
- // left the compensation at zero on exactly that surface and the
701
- // outgoing body rode the sheet's instant growth out of view (real
702
- // browser verification finding). This stub moves the leaving body only
703
- // once the swap event fires, so a pre-announce measurement reads zero.
704
- stubMotion();
705
- let leaveTop = 500;
706
- const proto = HTMLElement.prototype as unknown as {
707
- getBoundingClientRect: () => DOMRect;
708
- };
709
- const realRect = proto.getBoundingClientRect;
710
- proto.getBoundingClientRect = function (this: HTMLElement): DOMRect {
711
- const top = this.classList.contains("hk-stepflow-body")
712
- ? this.classList.contains("leaving")
713
- ? leaveTop
714
- : 500
715
- : 0;
716
- return { top, left: 0, bottom: top, right: 0, width: 0, height: 0, x: 0, y: top, toJSON: () => ({}) } as DOMRect;
717
- };
718
- try {
719
- const t = mountStepFlow({ initial: "a" });
720
- t.container.addEventListener(STEPFLOW_SWAP_EVENT, () => {
721
- // What the sheet's instant pin does to the content: the stage jumps
722
- // up by the delta.
723
- leaveTop = 100;
724
- });
725
- t.setCurrent("b");
726
- await flushSwap();
727
- const leaving = t.container.querySelector<HTMLElement>(".hk-stepflow-body.leaving");
728
- expect(leaving?.style.top).toBe("400px");
729
- } finally {
730
- proto.getBoundingClientRect = realRect;
731
- }
732
- });
733
-
734
- it("parks the new body too when a shrink takes the faded fast path", async () => {
735
- // The fast path IS the enter edge for a faded outgoing body, so a
736
- // shrink must run the same measurement + parking handshake there
737
- // instead of morphing in the exit slot (real-browser finding P5).
738
- stubMotion("0.15s", { leavingOpacity: "0" });
739
- stubHeights((text) => (text === "a-body" ? 220 : 100));
740
- const frames: FrameRequestCallback[] = [];
741
- vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
742
- frames.push(cb);
743
- return frames.length;
744
- });
745
- vi.stubGlobal("cancelAnimationFrame", () => {});
746
- const t = mountStepFlow({
747
- initial: "a",
748
- sheetHost: true,
749
- sweepSpan: { from: 220, to: 100 },
750
- });
751
- t.setCurrent("b");
752
- await flushSwap();
753
- // Swap again while the exiting body reads as fully faded: the new swap
754
- // drops it and goes straight to its enter edge — as a shrink.
755
- t.setCurrent("c");
756
- await flushSwap();
757
- // The staged frame commits the fade's "from" and applies the park.
758
- for (const cb of frames.splice(0)) cb(0);
759
- await flushSwap();
760
- const bodies = t.container.querySelector<HTMLElement>(".hk-stepflow-bodies");
761
- const active = t.container.querySelector<HTMLElement>(".hk-stepflow-body.active");
762
- expect(active?.textContent).toBe("c-body");
763
- expect(active?.classList.contains("hk-stepflow-enter-tail")).toBe(true);
764
- expect(active?.style.top).toBe("120px");
765
- expect(bodies?.style.minHeight).toBe("220px");
766
- });
767
-
768
- it("keeps the outgoing body's measured line across a pre-emption", async () => {
769
- // F1a: the survivor is still on stage and the sheet is still at the
770
- // height it grew to, so its compensating offset must survive the
771
- // pre-emption and only be cleared when that swap ends (clearing it
772
- // teleported the visible body by the whole delta in one frame).
773
- stubMotion();
774
- let leaveTop = 500;
775
- const proto = HTMLElement.prototype as unknown as {
776
- getBoundingClientRect: () => DOMRect;
777
- };
778
- const realRect = proto.getBoundingClientRect;
779
- proto.getBoundingClientRect = function (this: HTMLElement): DOMRect {
780
- const top = this.classList.contains("hk-stepflow-body")
781
- ? this.classList.contains("leaving")
782
- ? leaveTop
783
- : 500
784
- : 0;
785
- return { top, left: 0, bottom: top, right: 0, width: 0, height: 0, x: 0, y: top, toJSON: () => ({}) } as DOMRect;
786
- };
787
- try {
788
- const t = mountStepFlow({ initial: "a" });
789
- let announces = 0;
790
- t.container.addEventListener(STEPFLOW_SWAP_EVENT, () => {
791
- // The sheet pins its new height once; after that the layout does not
792
- // move again, so the second swap's own measurement reads zero and
793
- // ONLY a preserved offset can keep the body on its line.
794
- announces += 1;
795
- if (announces === 1) leaveTop = 100;
796
- });
797
- t.setCurrent("b");
798
- await flushSwap();
799
- const first = t.container.querySelector<HTMLElement>(".hk-stepflow-body.leaving");
800
- expect(first?.style.top).toBe("400px");
801
- // Navigate again while that body is still exiting.
802
- t.setCurrent("c");
803
- await flushSwap();
804
- const survivor = t.container.querySelector<HTMLElement>(".hk-stepflow-body.leaving");
805
- expect(survivor?.textContent).toBe("a-body");
806
- expect(survivor?.style.top).toBe("400px");
807
- // …and it is dropped with the swap, offset and all.
808
- endTransition(survivor);
809
- await flushSwap();
810
- endTransition(t.container.querySelector(".hk-stepflow-body.active"));
811
- await flushSwap();
812
- expect(t.container.querySelector(".hk-stepflow-body.active")?.textContent).toBe("c-body");
813
- expect(
814
- t.container.querySelector<HTMLElement>(".hk-stepflow-body.active")?.style.top ?? "",
815
- ).toBe("");
816
- } finally {
817
- proto.getBoundingClientRect = realRect;
818
- }
819
- });
820
-
821
- it("keeps the park when another surface publishes a landing", async () => {
822
- // The flow listens for landings on ITS host body only, so another
823
- // surface's settle cannot reach the park at all. (The two guard clauses
824
- // inside the listener — target and swap identity — are formally
825
- // redundant with that scoping and with disarmPhase; they are kept as
826
- // belt-and-braces and are not mutation-reachable.)
827
- stubMotion();
828
- stubHeights((text) => (text === "a-body" ? 220 : 100));
829
- const t = mountStepFlow({
830
- initial: "a",
831
- sheetHost: true,
832
- sweepSpan: { from: 220, to: 100 },
833
- });
834
- t.setCurrent("b");
835
- await flushSwap();
836
- endTransition(t.container.querySelector(".hk-stepflow-body.leaving"));
837
- await flushSwap();
838
- const bodies = t.container.querySelector<HTMLElement>(".hk-stepflow-bodies");
839
- expect(
840
- t.container
841
- .querySelector<HTMLElement>(".hk-stepflow-body.active")
842
- ?.classList.contains("hk-stepflow-enter-tail"),
843
- ).toBe(true);
844
- expect(bodies?.style.minHeight).toBe("220px");
845
-
846
- const foreign = document.createElement("div");
847
- foreign.className = "hk-modal-body";
848
- document.body.appendChild(foreign);
849
- containers.push(foreign);
850
- foreign.dispatchEvent(new CustomEvent(SHEET_SWEEP_SETTLE_EVENT));
851
- await flushSwap();
852
- expect(bodies?.style.minHeight).toBe("220px");
853
- expect(
854
- t.container
855
- .querySelector<HTMLElement>(".hk-stepflow-body.active")
856
- ?.classList.contains("hk-stepflow-enter-tail"),
857
- ).toBe(true);
858
- });
859
-
860
- it("measures a pre-empted swap against the stage the sheet is pinned to", async () => {
861
- // F1b: the stage still carries the DROPPED pending body's height (that is
862
- // what the sheet pinned itself to), so a body-based delta reads the
863
- // shorter survivor, mis-signs as a grow, and skips the park handshake —
864
- // leaving the new body unparked under a running fold.
865
- stubMotion();
866
- stubHeights((text) => (text === "b-body" ? 220 : 100));
867
- const t = mountStepFlow({
868
- initial: "a",
869
- sheetHost: true,
870
- sweepSpan: { from: 220, to: 100 },
871
- });
872
- const events: CustomEvent[] = [];
873
- t.container.addEventListener(STEPFLOW_SWAP_EVENT, (e) => {
874
- events.push(e as CustomEvent);
875
- });
876
- t.setCurrent("b");
877
- await flushSwap();
878
- // Second navigation mid-exit (the survivor is NOT faded): the ordinary
879
- // exit → enter path, with the stage's pin still standing.
880
- t.setCurrent("c");
881
- await flushSwap();
882
- // Only the first swap's grow announcement so far: a shrink announces at
883
- // its enter edge.
884
235
  expect(events.length).toBe(1);
885
236
  endTransition(t.container.querySelector(".hk-stepflow-body.leaving"));
886
237
  await flushSwap();
887
238
  expect(events.length).toBe(2);
888
- const detail = events[1]!.detail as { delta: number; phase: string };
889
- expect(detail.delta).toBe(-120);
890
- expect(detail.phase).toBe("enter");
891
- const active = t.container.querySelector<HTMLElement>(".hk-stepflow-body.active");
892
- expect(active?.classList.contains("hk-stepflow-enter-tail")).toBe(true);
893
- expect(active?.style.top).toBe("120px");
894
- });
895
-
896
- it("releases a park only on the sweep it parked against, and only after the layout is back", async () => {
897
- // Phase 4 of the real-engine teardown chain: an interrupting dance
898
- // publishes the settle of the sweep IT tore down, so a park must ignore
899
- // a foreign sweep's settle — and when its own arrives, the held pin and
900
- // offset are released a flush later, once the parked body is back in
901
- // flow (releasing them while it is still absolute let a remeasure read
902
- // the collapsed height and fold the sheet 300px too far).
903
- stubMotion();
904
- stubHeights((text) => (text === "a-body" ? 220 : 100));
905
- const t = mountStepFlow({
906
- initial: "a",
907
- sheetHost: true,
908
- sweepSpan: { from: 220, to: 100, sweep: 7 },
909
- });
910
- t.setCurrent("b");
911
- await flushSwap();
912
- endTransition(t.container.querySelector(".hk-stepflow-body.leaving"));
913
- await flushSwap();
914
- const bodies = t.container.querySelector<HTMLElement>(".hk-stepflow-bodies");
915
- const body = t.container.closest(".hk-modal-body")!;
916
- const parked = () =>
917
- t.container
918
- .querySelector<HTMLElement>(".hk-stepflow-body.active")
919
- ?.classList.contains("hk-stepflow-enter-tail") ?? false;
920
- expect(parked()).toBe(true);
921
- expect(bodies?.style.minHeight).toBe("220px");
922
-
923
- // A settle from ANOTHER sweep (the dance it interrupted): ignored.
924
- body.dispatchEvent(
925
- new CustomEvent(SHEET_SWEEP_SETTLE_EVENT, {
926
- detail: { sweep: 6 },
927
- }),
928
- );
929
- await flushSwap();
930
- expect(parked()).toBe(true);
931
- expect(bodies?.style.minHeight).toBe("220px");
932
-
933
- // Its own sweep's settle: released, and the geometry goes a flush later.
934
- body.dispatchEvent(
935
- new CustomEvent(SHEET_SWEEP_SETTLE_EVENT, {
936
- detail: { sweep: 7 },
937
- }),
938
- );
939
- expect(bodies?.style.minHeight).toBe("220px");
940
- await flushSwap();
941
- expect(parked()).toBe(false);
942
- expect(bodies?.style.minHeight).toBe("");
943
- });
944
-
945
- it("holds a park across a navigation and only lets go when its own sweep lands", async () => {
946
- // The enter-phase pre-emption path: navigating while a parked fold is
947
- // still sweeping must NOT release the pin or the parked body's offset
948
- // (clearing them synchronously moved a visible body by the whole fold
949
- // span in one frame — audit finding NEW-C, the F1a defect on the other
950
- // pre-emption route).
951
- stubMotion();
952
- stubHeights((text) => (text === "a-body" ? 220 : 100));
953
- const t = mountStepFlow({
954
- initial: "a",
955
- sheetHost: true,
956
- sweepSpan: { from: 220, to: 100, sweep: 7 },
957
- });
958
- t.setCurrent("b");
959
- await flushSwap();
960
- endTransition(t.container.querySelector(".hk-stepflow-body.leaving"));
961
- await flushSwap();
962
- const bodies = t.container.querySelector<HTMLElement>(".hk-stepflow-bodies");
963
- const body = t.container.closest(".hk-modal-body")!;
964
- expect(bodies?.style.minHeight).toBe("220px");
965
-
966
- // The parked body's own measured offset, which only survives if the
967
- // pre-emption leaves the held geometry alone.
968
- const parkedEl = t.container.querySelector<HTMLElement>(
969
- ".hk-stepflow-body.active",
970
- );
971
- expect(parkedEl?.style.top).toBe("120px");
972
-
973
- // Navigate while the fold is still sweeping.
974
- t.setCurrent("c");
975
- await flushSwap();
976
- // The held geometry survived the pre-emption: the body that was parked
977
- // is now the outgoing one and still carries its line.
978
- const survivor = t.container.querySelector<HTMLElement>(".hk-stepflow-body.leaving");
979
- expect(survivor?.style.top).toBe("120px");
980
- expect(bodies?.style.minHeight).not.toBe("");
981
- // …and the swap that owns it finally releases it.
982
- body.dispatchEvent(
983
- new CustomEvent(SHEET_SWEEP_SETTLE_EVENT, { detail: { sweep: 7 } }),
984
- );
985
- await flushSwap();
986
- expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBeGreaterThan(0);
987
- });
988
-
989
- it("releases a pin a pre-emption preserved when motion collapses mid-flight", async () => {
990
- // The pinned stage outlives the swap that owned it if the next
991
- // navigation resolves to the instant path (motion collapsed mid-flight,
992
- // e.g. a reduced-motion flip): that path creates no successor, so it has
993
- // to release the pin itself or the stage stays stuck at the old height.
994
- const setDuration = stubMotion();
995
- stubHeights((text) => (text === "a-body" ? 220 : 100));
996
- const t = mountStepFlow({ initial: "a", sheetHost: true });
997
- t.setCurrent("b");
998
- await flushSwap();
999
- const bodies = t.container.querySelector<HTMLElement>(".hk-stepflow-bodies");
1000
- expect(bodies?.style.minHeight).toBe("220px");
1001
-
1002
- // Motion collapses before the next navigation.
1003
- setDuration("0s");
1004
- t.setCurrent("c");
1005
- await flushSwap();
1006
- expect(bodies?.style.minHeight).toBe("");
1007
- expect(t.container.querySelector<HTMLElement>(".hk-stepflow-body.active")?.textContent).toBe(
1008
- "c-body",
1009
- );
1010
- });
1011
-
1012
- it("leaves no orphaned transition booked when a fast-path park releases", async () => {
1013
- // The park books the longer window its fold needs; the fast path must not
1014
- // re-book over it (that orphans the handle and keeps the animation bus —
1015
- // and its registry — reporting a transition nobody owns).
1016
- stubMotion("0.15s", { leavingOpacity: "0" });
1017
- stubHeights((text) => (text === "a-body" ? 220 : 100));
1018
- const frames: FrameRequestCallback[] = [];
1019
- vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
1020
- frames.push(cb);
1021
- return frames.length;
1022
- });
1023
- vi.stubGlobal("cancelAnimationFrame", () => {});
1024
- const t = mountStepFlow({
1025
- initial: "a",
1026
- sheetHost: true,
1027
- sweepSpan: { from: 220, to: 100, sweep: 5 },
1028
- });
1029
- t.setCurrent("b");
1030
- await flushSwap();
1031
- t.setCurrent("c");
1032
- await flushSwap();
1033
- for (const cb of frames.splice(0)) cb(0);
1034
- await flushSwap();
1035
- const body = t.container.closest(".hk-modal-body")!;
1036
- body.dispatchEvent(
1037
- new CustomEvent(SHEET_SWEEP_SETTLE_EVENT, { detail: { sweep: 5 } }),
1038
- );
1039
- await flushSwap();
1040
- expect(readHkRuntime("animationBus")?.transitions ?? 0).toBe(0);
239
+ expect((events[1]!.detail as { phase: string }).phase).toBe("enter");
240
+ expect((events[1]!.detail as { delta: number }).delta).toBe(-120);
1041
241
  });
1042
242
 
1043
243
  it("advances both phases on the watchdog when transitionend never arrives", async () => {
@@ -1046,93 +246,45 @@ describe("HkStepFlow two-phase swap (motion enabled)", () => {
1046
246
  t.setCurrent("b");
1047
247
  await flushSwap();
1048
248
  expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(2);
1049
- // Phase-1 watchdog (20ms + 350ms grace).
1050
249
  await outlivePhase();
1051
250
  expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(1);
1052
- expect(
1053
- t.container
1054
- .querySelector<HTMLElement>(".hk-stepflow-body.active")
1055
- ?.classList.contains("hk-stepflow-enter-pending"),
1056
- ).toBe(false);
1057
- // Phase-2 watchdog settles the swap entirely.
1058
251
  await outlivePhase();
1059
252
  expect(t.container.querySelector(".hk-stepflow-body")?.className).toBe(
1060
253
  "hk-stepflow-body active",
1061
254
  );
1062
255
  });
1063
256
 
1064
- it("mirrors the direction attribute for back navigation", async () => {
1065
- stubMotion();
1066
- const t = mountStepFlow({ initial: "c" });
1067
- t.setCurrent("a");
1068
- await flushSwap();
1069
- expect(
1070
- t.container.querySelector(".hk-stepflow-bodies")?.getAttribute("data-direction"),
1071
- ).toBe("back");
1072
- expect(
1073
- t.container.querySelector<HTMLElement>(".hk-stepflow-body.leaving")?.textContent,
1074
- ).toBe("c-body");
1075
- expect(
1076
- t.container
1077
- .querySelector<HTMLElement>(".hk-stepflow-body.active")
1078
- ?.classList.contains("hk-stepflow-enter-pending"),
1079
- ).toBe(true);
1080
- await outliveSwap();
1081
- expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(1);
1082
- });
1083
-
1084
257
  it("keeps the visible body on stage when a re-swap pre-empts the exit phase", async () => {
1085
258
  stubMotion();
1086
259
  const t = mountStepFlow({ initial: "a" });
1087
260
  t.setCurrent("b");
1088
261
  await flushSwap();
1089
- // b is still pending (invisible); swap again mid-exit.
1090
262
  t.setCurrent("c");
1091
263
  await flushSwap();
1092
264
  const leaving = t.container.querySelector<HTMLElement>(".hk-stepflow-body.leaving");
1093
265
  const pending = t.container.querySelector<HTMLElement>(".hk-stepflow-body.active");
1094
- expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(2);
1095
- // The body the user is looking at keeps leaving; the never-painted one
1096
- // is dropped (promoting it blanked the stage for 134ms).
1097
266
  expect(leaving?.textContent).toBe("a-body");
1098
- expect(leaving?.classList.contains("hk-stepflow-leave-to")).toBe(true);
1099
267
  expect(pending?.textContent).toBe("c-body");
1100
- expect(pending?.classList.contains("hk-stepflow-enter-pending")).toBe(true);
1101
268
  endTransition(leaving);
1102
269
  await flushSwap();
1103
270
  endTransition(t.container.querySelector(".hk-stepflow-body.active"));
1104
271
  await flushSwap();
1105
- expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(1);
1106
272
  expect(t.container.querySelector(".hk-stepflow-body")?.textContent).toBe("c-body");
1107
273
  });
1108
274
 
1109
- it("pre-empts a swap during the enter phase without stranding a body", async () => {
1110
- stubMotion();
275
+ it("fades the new body in immediately when the outgoing one already faded", async () => {
276
+ stubMotion("0.15s", { leavingOpacity: "0" });
1111
277
  const t = mountStepFlow({ initial: "a" });
1112
278
  t.setCurrent("b");
1113
279
  await flushSwap();
1114
- endTransition(t.container.querySelector(".hk-stepflow-body.leaving"));
1115
- await flushSwap();
1116
- // Enter phase running (b is fading in place); swap to c mid-fade.
1117
280
  t.setCurrent("c");
1118
281
  await flushSwap();
1119
- const leaving = t.container.querySelector<HTMLElement>(".hk-stepflow-body.leaving");
1120
- const pending = t.container.querySelector<HTMLElement>(".hk-stepflow-body.active");
1121
- expect(leaving?.textContent).toBe("b-body");
1122
- expect(pending?.textContent).toBe("c-body");
1123
- expect(pending?.classList.contains("hk-stepflow-enter-pending")).toBe(true);
1124
- await outliveSwap();
1125
- expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(1);
1126
- expect(t.container.querySelector(".hk-stepflow-body")?.textContent).toBe("c-body");
282
+ const active = t.container.querySelector<HTMLElement>(".hk-stepflow-body.active");
283
+ expect(active?.textContent).toBe("c-body");
284
+ expect(active?.classList.contains("hk-stepflow-enter-pending")).toBe(false);
1127
285
  });
1128
286
  });
1129
287
 
1130
- // ── Sticky-header pin strategy resolution (2026-09-14 scroll-pin wave) ──
1131
- // The timeline root rides HkScrollPin's contract; the strategy must flip
1132
- // to "offset" when the nearest host paints its gutters (data-pad-cover,
1133
- // HkModal) and stay "bleed" under a plain host. Behavioral, not
1134
- // tautological: happy-dom resolves the attribute scan fine (no custom
1135
- // properties involved).
1136
288
  describe("HkStepFlow sticky-header pin strategy", () => {
1137
289
  async function mountWithHost(hostAttrs: Record<string, string> | null) {
1138
290
  const container = document.createElement("div");
@@ -1157,7 +309,6 @@ describe("HkStepFlow sticky-header pin strategy", () => {
1157
309
  const app = createApp(Wrapper);
1158
310
  app.mount(container);
1159
311
  mounts.push(app);
1160
- // Resolution runs onMounted; the flip lands one flush later.
1161
312
  await nextTick();
1162
313
  await nextTick();
1163
314
  return container.querySelector<HTMLElement>(".hk-timeline");
@@ -1166,8 +317,6 @@ describe("HkStepFlow sticky-header pin strategy", () => {
1166
317
  it("resolves offset under a cover host (HkModal-style)", async () => {
1167
318
  const tl = await mountWithHost({ "data-pad-cover": "", "data-scroll-axis": "vertical" });
1168
319
  expect(tl!.dataset.strategy).toBe("offset");
1169
- expect(tl!.dataset.side).toBe("top");
1170
- expect(tl!.classList.contains("hk-scroll-pin")).toBe(true);
1171
320
  });
1172
321
 
1173
322
  it("keeps bleed under a plain host", async () => {