@celestia-island/hikari 0.55.42 → 0.55.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.42",
3
+ "version": "0.55.43",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -1,19 +1,23 @@
1
- // Crossfading step bodies for HkStepFlow.
1
+ // Direction-aware sliding step bodies for HkStepFlow.
2
2
  //
3
3
  // A swap renders BOTH bodies for the whole duration: the entering one in
4
4
  // the flow (so the container's height — and the hosting sheet's morph —
5
5
  // are the NEW geometry from frame one) and the leaving one absolutely
6
- // positioned over it. Exactly one body rides the sheet's moving top edge
7
- // while the other stays pinned (2026-09-21 user spec, round 7); the ride
8
- // and the opacity crossfade share one duration/ease pair so the sheet's
9
- // clip sweep, the ride and the fade all land together.
6
+ // positioned over it. This stylesheet owns ALL of the motion: direction,
7
+ // travel distance and easings are driven by the `data-direction`
8
+ // attribute and the staged `hk-stepflow-enter-from` / `hk-stepflow-leave-to` classes; the
9
+ // component only toggles those classes on a shared-animation-bus frame
10
+ // so the leave and the enter transitions start on the same style
11
+ // recalculation (2026-09-22 user directive: back to the classic slide,
12
+ // CSS as the base, animation context scheduling, zero black flashes).
10
13
  //
11
- // Duration resolves from one local custom property so consumers can tune
12
- // every phase at once; the default is the 0.3s family standard the spec
13
- // asked for.
14
+ // Duration and travel resolve from local custom properties so consumers
15
+ // can tune every phase at once; the 0.3s default matches the phone
16
+ // sheet's morph duration so the sweep and the slide land together.
14
17
 
15
18
  .hk-step-flow {
16
19
  --hk-stepflow-duration: 0.3s;
20
+ --hk-stepflow-travel: 24px;
17
21
  }
18
22
 
19
23
  // Breathing room between the step indicator and the step bodies. Without
@@ -29,7 +33,7 @@
29
33
  // Sticky header mode: positioning, surface and the whitespace contract
30
34
  // live on the shared .hk-scroll-pin rules (HkScrollPin.scss) — the
31
35
  // timeline root carries the class plus data-side/data-strategy. This
32
- // block keeps only what's step-flow-specific: the body gap folded into
36
+ // block keeps only what is step-flow-specific: the body gap folded into
33
37
  // the pinned header's own padding (so the tint stays continuous while
34
38
  // the body scrolls) and the legacy styling knobs mapped onto the pin's
35
39
  // custom properties. The doubled class selector must beat
@@ -49,18 +53,17 @@
49
53
  }
50
54
 
51
55
  // The swap stage: entering bodies own the flow's height; the leaving one
52
- // overlays it, out of flow and pointer-transparent for the fade.
56
+ // overlays it, out of flow and pointer-transparent for the slide.
53
57
  .hk-stepflow-bodies {
54
58
  position: relative;
55
59
  }
56
60
 
57
61
  .hk-stepflow-body {
58
- // The ride + fade (armed from script with a staged start state — see
59
- // the component's swap dance): same tokens for both properties so the
60
- // sheet's clip sweep, the ride and the crossfade stay in lockstep.
62
+ // Enter grammar (the base rule is what an ENTERING body computes once
63
+ // its staged `hk-stepflow-enter-from` class lifts): the classic expressive ease-out.
61
64
  transition:
62
- opacity var(--hk-stepflow-duration, 0.3s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1)),
63
- transform var(--hk-stepflow-duration, 0.3s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1));
65
+ opacity var(--hk-stepflow-duration, 0.3s) cubic-bezier(0.16, 1, 0.3, 1),
66
+ transform var(--hk-stepflow-duration, 0.3s) cubic-bezier(0.16, 1, 0.3, 1);
64
67
  }
65
68
 
66
69
  .hk-stepflow-body.leaving {
@@ -69,10 +72,46 @@
69
72
  left: 0;
70
73
  right: 0;
71
74
  pointer-events: none;
75
+ // Leave grammar: the sharp ease-in counterpart. Read when the staged
76
+ // `hk-stepflow-leave-to` class lands, so the exit keeps its own easing.
77
+ transition:
78
+ opacity var(--hk-stepflow-duration, 0.3s) cubic-bezier(0.5, 0, 0.75, 0),
79
+ transform var(--hk-stepflow-duration, 0.3s) cubic-bezier(0.5, 0, 0.75, 0);
80
+ }
81
+
82
+ // Direction-aware travel (the classic slide vocabulary): forward advances
83
+ // exit LEFT and enter from the RIGHT; back mirrors. `hk-stepflow-enter-from` is the
84
+ // entering body's staged start state, `hk-stepflow-leave-to` the leaving body's end
85
+ // state — both removed/applied together one bus frame after staging.
86
+ .hk-stepflow-bodies[data-direction="forward"] .hk-stepflow-body.hk-stepflow-enter-from,
87
+ .hk-stepflow-bodies[data-direction="back"] .hk-stepflow-body.hk-stepflow-leave-to {
88
+ opacity: 0;
89
+ transform: translateX(var(--hk-stepflow-travel, 24px));
90
+ }
91
+
92
+ .hk-stepflow-bodies[data-direction="forward"] .hk-stepflow-body.hk-stepflow-leave-to,
93
+ .hk-stepflow-bodies[data-direction="back"] .hk-stepflow-body.hk-stepflow-enter-from {
94
+ opacity: 0;
95
+ transform: translateX(calc(-1 * var(--hk-stepflow-travel, 24px)));
96
+ }
97
+
98
+ /* RTL: reading direction flips, so the slide mirrors — forward enters
99
+ * from the left and exits right (house pattern: HkListTransition.scss
100
+ * [dir="rtl"] form). */
101
+ [dir="rtl"] .hk-stepflow-bodies[data-direction="forward"] .hk-stepflow-body.hk-stepflow-enter-from,
102
+ [dir="rtl"] .hk-stepflow-bodies[data-direction="back"] .hk-stepflow-body.hk-stepflow-leave-to {
103
+ transform: translateX(calc(-1 * var(--hk-stepflow-travel, 24px)));
104
+ }
105
+
106
+ [dir="rtl"] .hk-stepflow-bodies[data-direction="forward"] .hk-stepflow-body.hk-stepflow-leave-to,
107
+ [dir="rtl"] .hk-stepflow-bodies[data-direction="back"] .hk-stepflow-body.hk-stepflow-enter-from {
108
+ transform: translateX(var(--hk-stepflow-travel, 24px));
72
109
  }
73
110
 
74
- /* Reduced motion: bodies swap instantly — no ride, no fade (the sheet
75
- * morph collapses the same way through its own duration tokens). */
111
+ /* Reduced motion: bodies swap instantly. The component's duration probe
112
+ * reads the zeroed duration and settles without staging, so this block
113
+ * is the single switch for the whole choreography (the sheet morph
114
+ * collapses the same way through its own duration tokens). */
76
115
  @media (prefers-reduced-motion: reduce) {
77
116
  .hk-stepflow-body {
78
117
  transition: none;
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Source contract for the stepflow direction-aware slide (2026-09-22 user
3
+ * directive: back to the classic slide scheme, re-based on pure CSS with
4
+ * the shared animation context driving state, zero black flashes).
5
+ *
6
+ * A swap renders BOTH bodies for the whole duration — the entering one in
7
+ * the flow, the leaving one absolutely positioned — and the stylesheet
8
+ * owns ALL motion: direction, travel and easings are attribute/class
9
+ * driven, so a refactor cannot silently reintroduce inline-style
10
+ * choreography, a vertical ride, or the retired out-in grammar. The
11
+ * component side is pinned to the animation bus (scheduleFrame /
12
+ * reportTransition, no bare rAF) and to deterministic settling where no
13
+ * transition runs (reduced motion / stylesheet-less runtimes).
14
+ */
15
+ import { describe, expect, it } from "vitest";
16
+ import { readFileSync } from "node:fs";
17
+ import { dirname, join } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ const here = dirname(fileURLToPath(import.meta.url));
21
+ const src = readFileSync(join(here, "HkStepFlow.scss"), "utf-8");
22
+ const tsx = readFileSync(join(here, "HkStepFlow.tsx"), "utf-8");
23
+
24
+ /** Top-level stylesheet rules as (selector-list, body) pairs. Nested
25
+ * blocks (e.g. the reduced-motion media query) come out as one rule
26
+ * whose body carries the inner braces — the direction rules this
27
+ * contract binds are all top-level. */
28
+ function extractRules(source: string): { selector: string; body: string }[] {
29
+ const rules: { selector: string; body: string }[] = [];
30
+ let i = 0;
31
+ while (i < source.length) {
32
+ const open = source.indexOf("{", i);
33
+ if (open === -1) break;
34
+ const selector = source
35
+ .slice(i, open)
36
+ .replace(/\/\*[\s\S]*?\*\//g, "")
37
+ .replace(/\/\/[^\n]*/g, "")
38
+ .trim();
39
+ let depth = 1;
40
+ let j = open + 1;
41
+ while (j < source.length && depth > 0) {
42
+ if (source[j] === "{") depth += 1;
43
+ else if (source[j] === "}") depth -= 1;
44
+ j += 1;
45
+ }
46
+ rules.push({ selector, body: source.slice(open + 1, j - 1) });
47
+ i = j;
48
+ }
49
+ return rules;
50
+ }
51
+
52
+ const scssRules = extractRules(src);
53
+
54
+ /** The declaration body of the ONE rule whose selector list carries the
55
+ * exact selector — zero hits means the selector vanished, two means the
56
+ * contract's premise broke; both are red. */
57
+ function blockFor(selector: string): string {
58
+ const hits = scssRules.filter((r) =>
59
+ r.selector.split(",").map((s) => s.trim()).includes(selector),
60
+ );
61
+ expect(hits, `exactly one rule carries ${selector}`).toHaveLength(1);
62
+ return hits[0]!.body;
63
+ }
64
+
65
+ const TRAVEL_POSITIVE = "translateX(var(--hk-stepflow-travel, 24px))";
66
+ const TRAVEL_NEGATIVE = "translateX(calc(-1 * var(--hk-stepflow-travel, 24px))";
67
+
68
+ describe("HkStepFlow slide contract", () => {
69
+ it("pins the shared duration and travel tokens", () => {
70
+ const root = src.match(/\.hk-step-flow\s*\{[^}]*\}/)![0]!;
71
+ expect(root).toContain("--hk-stepflow-duration: 0.3s;");
72
+ expect(root).toContain("--hk-stepflow-travel: 24px;");
73
+ });
74
+
75
+ it("declares the enter grammar on the base body rule", () => {
76
+ const rule = src.match(/\.hk-stepflow-body\s*\{[^}]*\}/)![0]!;
77
+ const transitions = rule.match(/transition:\s*[^;}]+/g) ?? [];
78
+ expect(transitions).toHaveLength(1);
79
+ const decl = transitions[0]!;
80
+ expect(decl).toContain("opacity");
81
+ expect(decl).toContain("transform");
82
+ expect(decl).toContain("var(--hk-stepflow-duration, 0.3s)");
83
+ // The classic expressive ease-out on enter.
84
+ expect(decl).toContain("cubic-bezier(0.16, 1, 0.3, 1)");
85
+ });
86
+
87
+ it("keeps the leaving body out of the flow with the leave grammar", () => {
88
+ const rule = src.match(/\.hk-stepflow-body\.leaving\s*\{[^}]*\}/)![0]!;
89
+ expect(rule).toContain("position: absolute");
90
+ expect(rule).toContain("pointer-events: none");
91
+ // The classic sharp ease-in on leave.
92
+ expect(rule).toContain("cubic-bezier(0.5, 0, 0.75, 0)");
93
+ expect(rule).toContain("var(--hk-stepflow-duration, 0.3s)");
94
+ });
95
+
96
+ it("binds each direction's travel sign to its exact selector", () => {
97
+ // The classic vocabulary, sign-bound (2026-09-22 R1 teeth gap: pinning
98
+ // selector strings plus the EXISTENCE of both translateX forms stayed
99
+ // green under a sign inversion — each selector must bind its sign):
100
+ // forward = enter from the RIGHT (+travel), exit LEFT (−travel);
101
+ // back mirrors; RTL flips the whole mapping.
102
+ const body = ".hk-stepflow-body";
103
+ const stage = ".hk-stepflow-bodies";
104
+ const ltr: Array<[string, string]> = [
105
+ [`${stage}[data-direction="forward"] ${body}.hk-stepflow-enter-from`, TRAVEL_POSITIVE],
106
+ [`${stage}[data-direction="forward"] ${body}.hk-stepflow-leave-to`, TRAVEL_NEGATIVE],
107
+ [`${stage}[data-direction="back"] ${body}.hk-stepflow-enter-from`, TRAVEL_NEGATIVE],
108
+ [`${stage}[data-direction="back"] ${body}.hk-stepflow-leave-to`, TRAVEL_POSITIVE],
109
+ ];
110
+ const rtl: Array<[string, string]> = [
111
+ [`[dir="rtl"] ${stage}[data-direction="forward"] ${body}.hk-stepflow-enter-from`, TRAVEL_NEGATIVE],
112
+ [`[dir="rtl"] ${stage}[data-direction="forward"] ${body}.hk-stepflow-leave-to`, TRAVEL_POSITIVE],
113
+ [`[dir="rtl"] ${stage}[data-direction="back"] ${body}.hk-stepflow-enter-from`, TRAVEL_POSITIVE],
114
+ [`[dir="rtl"] ${stage}[data-direction="back"] ${body}.hk-stepflow-leave-to`, TRAVEL_NEGATIVE],
115
+ ];
116
+ for (const [selector, sign] of ltr) {
117
+ const block = blockFor(selector);
118
+ expect(block, `${selector} must carry ${sign}`).toContain(sign);
119
+ const other = sign === TRAVEL_POSITIVE ? TRAVEL_NEGATIVE : TRAVEL_POSITIVE;
120
+ expect(block, `${selector} must not carry ${other}`).not.toContain(other);
121
+ expect(block).toContain("opacity: 0");
122
+ }
123
+ // RTL blocks are transform-only overrides over the LTR grammar.
124
+ for (const [selector, sign] of rtl) {
125
+ const block = blockFor(selector);
126
+ expect(block, `${selector} must carry ${sign}`).toContain(sign);
127
+ const other = sign === TRAVEL_POSITIVE ? TRAVEL_NEGATIVE : TRAVEL_POSITIVE;
128
+ expect(block, `${selector} must not carry ${other}`).not.toContain(other);
129
+ }
130
+ // The stage keeps its relative positioning for the overlay grammar.
131
+ expect(src).toMatch(/\.hk-stepflow-bodies\s*\{[^}]*position:\s*relative/);
132
+ // The component renders the direction attribute the CSS keys off.
133
+ expect(tsx).toContain("data-direction={dir}");
134
+ });
135
+
136
+ it("keeps all motion in the stylesheet — no inline style choreography", () => {
137
+ // The 2026-09-22 directive: CSS is the base. The component toggles
138
+ // classes only; it must never write motion styles or compute rides.
139
+ expect(tsx).not.toContain("style.transition");
140
+ expect(tsx).not.toContain("style.transform");
141
+ expect(tsx).not.toContain("style.opacity");
142
+ expect(tsx).not.toContain("translateY");
143
+ });
144
+
145
+ it("schedules state on the shared animation bus, never on bare rAF", () => {
146
+ expect(tsx).toContain('from "../runtime/animationBus"');
147
+ expect(tsx).toContain("scheduleFrame(");
148
+ expect(tsx).toContain("reportTransition(");
149
+ expect(tsx).not.toContain("requestAnimationFrame(");
150
+ });
151
+
152
+ it("settles deterministically where no transition runs", () => {
153
+ // The duration probe: zero (reduced motion / stylesheet-less runtime)
154
+ // selects the atomic instant-settle path — the debt pin for chest's
155
+ // transitionend-less wizard tests.
156
+ expect(tsx).toContain("bodyTransitionMs");
157
+ expect(tsx).toContain("transitionDuration");
158
+ // The motion path keeps a watchdog so a lost transitionend cannot
159
+ // freeze the swap (same grammar as the sheet morph).
160
+ expect(tsx).toContain("SWAP_WATCHDOG_GRACE_MS");
161
+ expect(tsx).toContain("addEventListener(\"transitionend\"");
162
+ // transitionend bubbles — only the leaving body's own transitions may
163
+ // settle the swap (R3 spot mutation: removing this guard survived the
164
+ // suite, so it is pinned here).
165
+ expect(tsx).toContain("event.target === goneEl");
166
+ });
167
+
168
+ it("dispatches the swap passthrough so the host sheet morphs in lockstep", () => {
169
+ // The component side of the lockstep contract: the swap dispatches
170
+ // the bubbling event the moment the entering body owns the flow
171
+ // height, and the modal side short-circuits its settle debounce.
172
+ expect(tsx).toContain("STEPFLOW_SWAP_EVENT");
173
+ expect(tsx).toMatch(/new CustomEvent\(STEPFLOW_SWAP_EVENT/);
174
+ const modal = readFileSync(join(here, "HkModal.tsx"), "utf-8");
175
+ expect(modal).toContain('addEventListener(STEPFLOW_SWAP_EVENT');
176
+ expect(modal).toMatch(/removeEventListener\(STEPFLOW_SWAP_EVENT/);
177
+ });
178
+
179
+ it("retires the crossfade ride and the retired out-in classes", () => {
180
+ // Neither the round-7 vertical ride nor the pre-0.55.38 Vue
181
+ // transition class grammar may sneak back in, and no media block may
182
+ // fork step-body behaviour per viewport.
183
+ expect(tsx).not.toContain("hk-stepflow-fwd");
184
+ expect(tsx).not.toContain("hk-stepflow-back");
185
+ expect(src).not.toContain("hk-stepflow-fwd");
186
+ expect(src).not.toContain("hk-stepflow-back");
187
+ expect(src).not.toMatch(/@media[^{]*max-width/);
188
+ });
189
+
190
+ it("zeroes the transition under reduced motion so the probe settles", () => {
191
+ expect(src).toMatch(
192
+ /prefers-reduced-motion:\s*reduce\)\s*\{[\s\S]{0,160}?\.hk-stepflow-body\s*\{\s*transition:\s*none/,
193
+ );
194
+ });
195
+ });
@@ -1,17 +1,22 @@
1
1
  import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import { createApp, defineComponent, h, nextTick, ref } from "vue";
3
3
 
4
- import HkStepFlow from "./HkStepFlow";
4
+ import HkStepFlow, { STEPFLOW_SWAP_EVENT } from "./HkStepFlow";
5
5
  import type { StepFlowSlotProps } from "./HkStepFlow";
6
6
 
7
7
  /**
8
8
  * HkStepFlow contract tests. House style: no @vue/test-utils dependency —
9
9
  * raw createApp mounts on shared containers torn down after each case.
10
10
  *
11
- * Note on transition-class assertions: happy-dom does not run stylesheet
12
- * transitions, so Vue's `out-in` leave phase leaves the leave-from/leave-
13
- * active classes in place until its rAF/settling timers fire — checking
14
- * class names right after `nextTick` is deterministic here.
11
+ * Two environment shapes are exercised:
12
+ *
13
+ * - DEFAULT (happy-dom, no stylesheet): the component's duration probe
14
+ * reads a zero transition duration, so every swap settles INSTANTLY —
15
+ * one body in the DOM, no timers to outlive, no transitionend to wait
16
+ * for. This is the deterministic shape chest's wizard tests rely on.
17
+ * - MOTION STUBBED (`getComputedStyle` reports 0.3s): the full staged
18
+ * slide choreography runs — staged start classes, one bus frame, then
19
+ * the synchronized leave/enter transitions and the watchdog cleanup.
15
20
  */
16
21
 
17
22
  const mounts: ReturnType<typeof createApp>[] = [];
@@ -68,17 +73,38 @@ function mountStepFlow(options: {
68
73
  return { container, setCurrent: (key) => { current.value = key; } };
69
74
  }
70
75
 
71
- /** Let the swap's choreography timers (480ms sweep cleanup) settle. */
72
- async function settle(): Promise<void> {
76
+ /** Let the swap watch's async flush (post render + internal nextTick) land. */
77
+ async function flushSwap(): Promise<void> {
78
+ await nextTick();
79
+ await nextTick();
80
+ await nextTick();
81
+ }
82
+
83
+ /** Wait for the animation bus's staging frame(s) to fire. */
84
+ async function awaitBusFrame(): Promise<void> {
73
85
  await new Promise((resolve) => setTimeout(resolve, 80));
74
86
  }
75
87
 
76
- /** Outlive the 480ms leaving-body cleanup timer. */
88
+ /** Outlive the watchdog (0.3s stubbed duration + 350ms grace). */
77
89
  async function outliveSwap(): Promise<void> {
78
- await new Promise((resolve) => setTimeout(resolve, 540));
90
+ await new Promise((resolve) => setTimeout(resolve, 720));
91
+ }
92
+
93
+ /** Force the motion path: the duration probe sees a 0.3s transition. */
94
+ function stubMotion(duration = "0.3s"): void {
95
+ const real = window.getComputedStyle.bind(window);
96
+ vi.spyOn(window, "getComputedStyle").mockImplementation(
97
+ (el: Element, pseudoElt?: string | null): CSSStyleDeclaration => {
98
+ if (el instanceof HTMLElement && el.classList.contains("hk-stepflow-body")) {
99
+ return { transitionDuration: duration } as CSSStyleDeclaration;
100
+ }
101
+ return real(el, pseudoElt ?? undefined);
102
+ },
103
+ );
79
104
  }
80
105
 
81
106
  afterEach(() => {
107
+ vi.restoreAllMocks();
82
108
  for (const app of mounts.splice(0)) app.unmount();
83
109
  for (const el of containers.splice(0)) el.remove();
84
110
  });
@@ -107,46 +133,42 @@ describe("HkStepFlow", () => {
107
133
  const t = mountStepFlow({ initial: "a" });
108
134
  expect(t.container.querySelector(".hk-stepflow-body")?.textContent).toBe("a-body");
109
135
  t.setCurrent("d");
110
- await nextTick();
111
- await nextTick();
112
- await new Promise((resolve) => setTimeout(resolve, 60));
113
- expect(t.container.querySelector(".hk-stepflow-body.active")?.textContent).toBe("d-body");
114
- await outliveSwap();
115
- // The leaving body is gone after the sweep window; exactly one body
116
- // remains and it is the new step's.
136
+ await flushSwap();
137
+ // Stylesheet-less runtime → instant settle: exactly one body, the new
138
+ // step's, with no leaving residue and no timers to outlive.
117
139
  const bodies = t.container.querySelectorAll(".hk-stepflow-body");
118
140
  expect(bodies.length).toBe(1);
119
141
  expect(bodies[0]!.className).toBe("hk-stepflow-body active");
120
142
  expect(bodies[0]!.textContent).toBe("d-body");
121
143
  });
122
144
 
123
- it("crossfades both bodies for the whole swap window (2026-09-21 spec)", async () => {
145
+ it("settles deterministically to a single body where no transition runs", async () => {
146
+ // The 2026-09-22 debt pin: chest's LoginView MFA suite broke because
147
+ // the crossfade kept a leaving body alive for hundreds of ms in
148
+ // transitionend-less environments. With a zero probed duration the
149
+ // swap is atomic — the DOM NEVER carries two steps.
124
150
  const t = mountStepFlow({ initial: "a" });
151
+ const events: CustomEvent[] = [];
152
+ t.container.addEventListener(STEPFLOW_SWAP_EVENT, (e) => {
153
+ events.push(e as CustomEvent);
154
+ });
125
155
  t.setCurrent("b");
126
- await nextTick();
127
- await nextTick();
128
- await new Promise((resolve) => setTimeout(resolve, 60));
129
- const leaving = t.container.querySelector<HTMLElement>(".hk-stepflow-body.leaving");
130
- const active = t.container.querySelector<HTMLElement>(".hk-stepflow-body.active");
131
- // Both bodies coexist; the leaving one is out of the flow and the
132
- // pair carries the crossfade's END opacities (armed inline —
133
- // happy-dom does not run the stylesheet transition).
134
- expect(leaving).not.toBeNull();
135
- expect(active).not.toBeNull();
136
- expect(leaving?.textContent).toBe("a-body");
137
- expect(active?.textContent).toBe("b-body");
138
- expect(leaving?.style.opacity).toBe("0");
139
- expect(active?.style.opacity).toBe("1");
140
- // A rapid re-swap mid-fade drops the stale leaving body outright.
156
+ await flushSwap();
157
+ expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(1);
158
+ expect(t.container.querySelector(".hk-stepflow-body.active")?.textContent).toBe("b-body");
159
+ expect(t.container.querySelector(".hk-stepflow-body.leaving")).toBeNull();
160
+ // Rapid successive swaps stay single-bodied the whole way through.
141
161
  t.setCurrent("c");
142
- await nextTick();
143
- await new Promise((resolve) => setTimeout(resolve, 60));
144
- const after = t.container.querySelectorAll(".hk-stepflow-body");
145
- expect(after.length).toBe(2);
146
- expect(t.container.querySelector(".hk-stepflow-body.leaving")?.textContent).toBe("b-body");
147
- expect(t.container.querySelector(".hk-stepflow-body.active")?.textContent).toBe("c-body");
148
- await outliveSwap();
162
+ await flushSwap();
163
+ t.setCurrent("d");
164
+ await flushSwap();
149
165
  expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(1);
166
+ expect(t.container.querySelector(".hk-stepflow-body.active")?.textContent).toBe("d-body");
167
+ // The sheet passthrough fired for every swap, carrying a numeric delta.
168
+ expect(events.length).toBe(3);
169
+ for (const e of events) {
170
+ expect(typeof (e.detail as { delta: number }).delta).toBe("number");
171
+ }
150
172
  });
151
173
 
152
174
  it("passes key/index/direction to scoped slots across navigation", async () => {
@@ -155,18 +177,17 @@ describe("HkStepFlow", () => {
155
177
  expect(seen.map((s) => `${s.key}:${s.index}:${s.direction}`)).toEqual(["a:0:forward"]);
156
178
 
157
179
  t.setCurrent("c");
158
- await settle();
180
+ await flushSwap();
159
181
  expect(seen.at(-1)).toEqual({ key: "c", index: 2, direction: "forward" });
160
182
 
161
183
  t.setCurrent("b");
162
- await settle();
184
+ await flushSwap();
163
185
  expect(seen.at(-1)).toEqual({ key: "b", index: 1, direction: "back" });
164
186
 
165
187
  // Returning forward again flips the direction back.
166
188
  t.setCurrent("d");
167
- await settle();
189
+ await flushSwap();
168
190
  expect(seen.at(-1)?.direction).toBe("forward");
169
- await settle();
170
191
  });
171
192
 
172
193
  it("emits update:modelValue when a clickable completed step is selected", async () => {
@@ -198,10 +219,9 @@ describe("HkStepFlow", () => {
198
219
  expect(first?.getAttribute("data-status")).toBe("completed");
199
220
  first.dispatchEvent(new MouseEvent("click"));
200
221
  await nextTick();
201
- await settle();
222
+ await flushSwap();
202
223
  expect(selected).toEqual(["a"]);
203
224
  expect(container.querySelector(".hk-stepflow-body.active")?.textContent).toBe("");
204
- await settle();
205
225
  });
206
226
 
207
227
  it("renders an empty body without warnings for an unknown key", async () => {
@@ -210,7 +230,7 @@ describe("HkStepFlow", () => {
210
230
  const t = mountStepFlow({ initial: "a" });
211
231
  t.setCurrent("does-not-exist");
212
232
  await nextTick();
213
- await settle();
233
+ await flushSwap();
214
234
  const body = t.container.querySelector(".hk-stepflow-body.active");
215
235
  expect(body).not.toBeNull();
216
236
  expect(body?.textContent).toBe("");
@@ -262,9 +282,99 @@ describe("HkStepFlow", () => {
262
282
  // remembered index follows the array to 3, so 1 < 3 correctly reads BACK.
263
283
  current.value = "c";
264
284
  await nextTick();
265
- await settle();
285
+ await flushSwap();
266
286
  expect(seen.at(-1)).toEqual({ key: "c", index: 1, direction: "back" });
267
- await settle();
287
+ });
288
+ });
289
+
290
+ // ── Motion-stubbed slide choreography ─────────────────────────────────
291
+ // `getComputedStyle` reports a 0.3s duration for step bodies, so the full
292
+ // staged slide runs: staged start classes → one bus frame → synchronized
293
+ // leave/enter transitions → transitionend/watchdog cleanup.
294
+ describe("HkStepFlow slide swap (motion enabled)", () => {
295
+ it("stages both bodies, then starts the synchronized slide on the bus frame", async () => {
296
+ stubMotion();
297
+ const t = mountStepFlow({ initial: "a" });
298
+ const events: CustomEvent[] = [];
299
+ t.container.addEventListener(STEPFLOW_SWAP_EVENT, (e) => {
300
+ events.push(e as CustomEvent);
301
+ });
302
+ t.setCurrent("b");
303
+ await flushSwap();
304
+
305
+ // Staged: both bodies coexist; the leaving one overlays the flow
306
+ // WITHOUT its end state yet, the entering one holds its start state.
307
+ const leaving = t.container.querySelector<HTMLElement>(".hk-stepflow-body.leaving");
308
+ const active = t.container.querySelector<HTMLElement>(".hk-stepflow-body.active");
309
+ expect(leaving).not.toBeNull();
310
+ expect(active).not.toBeNull();
311
+ expect(leaving?.textContent).toBe("a-body");
312
+ expect(active?.textContent).toBe("b-body");
313
+ expect(leaving?.classList.contains("hk-stepflow-leave-to")).toBe(false);
314
+ expect(active?.classList.contains("hk-stepflow-enter-from")).toBe(true);
315
+ expect(
316
+ t.container.querySelector(".hk-stepflow-bodies")?.getAttribute("data-direction"),
317
+ ).toBe("forward");
318
+ // The sheet passthrough fired as the entering body took the flow.
319
+ expect(events.length).toBe(1);
320
+
321
+ // The bus frame flips both bodies together: leave end state on, enter
322
+ // start state off — both transitions start on one recalculation.
323
+ await awaitBusFrame();
324
+ expect(leaving?.classList.contains("hk-stepflow-leave-to")).toBe(true);
325
+ expect(active?.classList.contains("hk-stepflow-enter-from")).toBe(false);
326
+ // The slide is still in flight: both bodies remain mounted.
327
+ expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(2);
328
+
329
+ // happy-dom never fires transitionend — the watchdog settles the swap.
330
+ await outliveSwap();
331
+ const bodies = t.container.querySelectorAll(".hk-stepflow-body");
332
+ expect(bodies.length).toBe(1);
333
+ expect(bodies[0]!.className).toBe("hk-stepflow-body active");
334
+ expect(bodies[0]!.textContent).toBe("b-body");
335
+ });
336
+
337
+ it("mirrors the direction attribute for back navigation", async () => {
338
+ stubMotion();
339
+ const t = mountStepFlow({ initial: "c" });
340
+ t.setCurrent("a");
341
+ await flushSwap();
342
+ expect(
343
+ t.container.querySelector(".hk-stepflow-bodies")?.getAttribute("data-direction"),
344
+ ).toBe("back");
345
+ // Same staged grammar, mirrored travel lives in the stylesheet.
346
+ const leaving = t.container.querySelector<HTMLElement>(".hk-stepflow-body.leaving");
347
+ const active = t.container.querySelector<HTMLElement>(".hk-stepflow-body.active");
348
+ expect(leaving?.textContent).toBe("c-body");
349
+ expect(active?.classList.contains("hk-stepflow-enter-from")).toBe(true);
350
+ await awaitBusFrame();
351
+ expect(leaving?.classList.contains("hk-stepflow-leave-to")).toBe(true);
352
+ await outliveSwap();
353
+ expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(1);
354
+ });
355
+
356
+ it("drops the stale leaving body outright on a rapid re-swap", async () => {
357
+ stubMotion();
358
+ const t = mountStepFlow({ initial: "a" });
359
+ t.setCurrent("b");
360
+ await flushSwap();
361
+ await awaitBusFrame();
362
+ // The first swap is running; re-swap before it settles.
363
+ t.setCurrent("c");
364
+ await flushSwap();
365
+ const leaving = t.container.querySelector<HTMLElement>(".hk-stepflow-body.leaving");
366
+ const active = t.container.querySelector<HTMLElement>(".hk-stepflow-body.active");
367
+ expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(2);
368
+ expect(leaving?.textContent).toBe("b-body");
369
+ expect(active?.textContent).toBe("c-body");
370
+ // The new swap restages: no leftover end state on the new leaving body.
371
+ expect(leaving?.classList.contains("hk-stepflow-leave-to")).toBe(false);
372
+ expect(active?.classList.contains("hk-stepflow-enter-from")).toBe(true);
373
+ await awaitBusFrame();
374
+ await outliveSwap();
375
+ const bodies = t.container.querySelectorAll(".hk-stepflow-body");
376
+ expect(bodies.length).toBe(1);
377
+ expect(bodies[0]!.textContent).toBe("c-body");
268
378
  });
269
379
  });
270
380
 
@@ -11,6 +11,11 @@ import {
11
11
  import HkTimeline from "./HkTimeline";
12
12
  import type { TimelineCollapse, TimelineStep } from "./HkTimeline";
13
13
  import { SCROLL_HOST_CLASS } from "./HkScrollPin";
14
+ import {
15
+ reportTransition,
16
+ scheduleFrame,
17
+ type AnimationHandle,
18
+ } from "../runtime/animationBus";
14
19
 
15
20
  // Sticky header mode rides the shared scroll-pin contract (class + data
16
21
  // attributes on the timeline root); the pin stylesheet ships those
@@ -35,44 +40,82 @@ interface BodyEntry {
35
40
  /** The step key this body renders. */
36
41
  key: string;
37
42
  /** `active` bodies sit in the flow; a `leaving` body is out of flow
38
- * (absolutely positioned) for the crossfade's duration. */
43
+ * (absolutely positioned) for the slide's duration. */
39
44
  phase: "active" | "leaving";
40
45
  }
41
46
 
42
- /** A swap whose leaving body was handed its end state (transition armed). */
47
+ /** A swap in flight: the leaving body slides out while the entering one
48
+ * slides in. All MOTION is stylesheet-owned (the `data-direction`
49
+ * attribute plus the `hk-stepflow-enter-from`/`hk-stepflow-leave-to` classes); script only
50
+ * schedules the staging frame on the shared animation bus and books
51
+ * the cleanup. */
43
52
  interface RunningSwap {
44
- entryId: number;
45
- timer: ReturnType<typeof setTimeout>;
53
+ leavingId: number;
54
+ enteringId: number;
55
+ /** The staging frame handle (disconnected when a re-swap pre-empts). */
56
+ frame: AnimationHandle | null;
57
+ /** Bus bookkeeping for the CSS sweep, held for its duration. */
58
+ report: AnimationHandle | null;
59
+ /** Watchdog: settle even if transitionend never arrives. */
60
+ timer: ReturnType<typeof setTimeout> | null;
61
+ listenEl: HTMLElement | null;
62
+ onEnded: ((event: TransitionEvent) => void) | null;
46
63
  }
47
64
 
48
65
  export const STEPFLOW_SWAP_EVENT = "hk-stepflow-swap";
49
66
 
67
+ /** Extra grace on top of the resolved duration before the watchdog
68
+ * settles the swap without a transitionend — the same grammar as the
69
+ * sheet morph's sweep watchdog. */
70
+ const SWAP_WATCHDOG_GRACE_MS = 350;
71
+
72
+ /**
73
+ * Resolve the body's configured transition duration in milliseconds.
74
+ * A zero result means the swap settles INSTANTLY: reduced-motion users
75
+ * (the stylesheet zeroes the transition) and stylesheet-less runtimes
76
+ * (test environments) both get a deterministic single-body DOM with no
77
+ * timers to outlive and no transitionend to wait for.
78
+ */
79
+ function bodyTransitionMs(el: HTMLElement | null): number {
80
+ if (!el) return 0;
81
+ const view = el.ownerDocument?.defaultView;
82
+ if (!view) return 0;
83
+ const raw = view.getComputedStyle(el).transitionDuration ?? "";
84
+ // Multiple entries ("0.3s, 0.3s") share one duration here — the first
85
+ // is representative; "0.3s" and "300ms" forms both parse.
86
+ const first = raw.split(",")[0]?.trim() ?? "";
87
+ const match = /^([0-9]*\.?[0-9]+)(ms|s)$/.exec(first);
88
+ if (!match) return 0;
89
+ const value = Number(match[1]);
90
+ return match[2] === "ms" ? value : value * 1000;
91
+ }
92
+
50
93
  /**
51
94
  * Generic step-flow container: an optional HkTimeline header bound to
52
- * `modelValue` plus a crossfading body fed purely by named slots keyed
53
- * by step key. Navigation state lives in the consumer — the component
54
- * only translates `modelValue` changes into body swaps and echoes
55
- * timeline selections upward.
95
+ * `modelValue` plus a direction-aware sliding body fed purely by named
96
+ * slots keyed by step key. Navigation state lives in the consumer — the
97
+ * component only translates `modelValue` changes into body swaps and
98
+ * echoes timeline selections upward.
56
99
  *
57
- * The swap choreography (2026-09-21 user spec, round 7): old and new
58
- * bodies cross-fade SIMULTANEOUSLY for the whole `--hk-stepflow-duration`
59
- * (default 0.3s), and exactly one of them rides the sheet's moving top
60
- * edge while the other stays pinned at the post-swap geometry:
61
- *
62
- * - SHRINK (old body taller): the sheet's top edge folds DOWN, so the
63
- * OLD body rides it (translateY −Δ → 0, glued to the edge) while it
64
- * fades out; the NEW body is already at its final top and only fades
65
- * in. (The container's layout height is the new body's from frame
66
- * one, so the sheet morph and the crossfade start together.)
67
- * - GROW: mirrored — the OLD body stays where it was (its top is below
68
- * the container's new top by Δ, i.e. exactly the band the sheet's
69
- * staged clip keeps visible) and fades out; the NEW body rides the
70
- * edge upward (translateY +Δ → 0) while fading in.
100
+ * The swap choreography (2026-09-22 user directive: back to the classic
101
+ * direction-aware slide, re-based on pure CSS with the animation context
102
+ * driving state) — old and new bodies slide SIMULTANEOUSLY for the whole
103
+ * `--hk-stepflow-duration` (default 0.3s): forward advances exit left /
104
+ * enter from the right, back mirrors it. The entering body owns the
105
+ * flow's height from frame one (so the hosting sheet's morph targets the
106
+ * NEW geometry immediately) while the leaving body overlays it out of
107
+ * flow; travel distance, direction and easings all live in the
108
+ * stylesheet. State flips are scheduled on the shared animation bus:
109
+ * one staging frame after the DOM patch commits the start states, both
110
+ * bodies' classes flip in the same patch so leave and enter start on
111
+ * the same style recalculation — the simultaneous grammar is what keeps
112
+ * the retired `out-in` slide's empty mid-frame (and its raster flash)
113
+ * from coming back.
71
114
  *
72
115
  * Each swap also dispatches `hk-stepflow-swap` (bubbles, detail
73
116
  * `{ delta }`) from the flow root — the hosting modal listens for it and
74
117
  * re-measures immediately, skipping the morph's settle debounce so the
75
- * sheet's clip sweep starts on the same frame as the crossfade.
118
+ * sheet's clip sweep starts on the same frame as the slide.
76
119
  */
77
120
  export default defineComponent({
78
121
  name: "HkStepFlow",
@@ -136,19 +179,31 @@ export default defineComponent({
136
179
  },
137
180
  );
138
181
 
139
- // ── Crossfade body bookkeeping ────────────────────────────────────
182
+ // ── Swap body bookkeeping ─────────────────────────────────────────
140
183
  let mountSeq = 0;
141
184
  const bodies = ref<BodyEntry[]>([
142
185
  { id: mountSeq++, key: props.modelValue, phase: "active" },
143
186
  ]);
187
+ // `swap` is a plain let; `swapStage` carries the reactivity. Every
188
+ // mutation of `swap` is paired with either a `swapStage` or a
189
+ // `bodies` write, so renders always re-read the pair (see the watch
190
+ // and endSwap below).
144
191
  let swap: RunningSwap | null = null;
192
+ const swapStage = ref<"idle" | "staged" | "running">("idle");
145
193
  const flowRef = ref<HTMLDivElement | null>(null);
146
194
 
147
195
  function endSwap(): void {
148
196
  if (!swap) return;
149
- clearTimeout(swap.timer);
150
- const id = swap.entryId;
197
+ const handle = swap;
151
198
  swap = null;
199
+ swapStage.value = "idle";
200
+ handle.frame?.disconnect();
201
+ if (handle.timer !== null) clearTimeout(handle.timer);
202
+ handle.report?.disconnect();
203
+ if (handle.listenEl && handle.onEnded) {
204
+ handle.listenEl.removeEventListener("transitionend", handle.onEnded);
205
+ }
206
+ const id = handle.leavingId;
152
207
  bodies.value = bodies.value.filter((b) => b.id !== id);
153
208
  }
154
209
 
@@ -168,27 +223,69 @@ export default defineComponent({
168
223
  () => props.modelValue,
169
224
  async (next, prev) => {
170
225
  if (next === prev) return;
171
- // A swap while the previous one is still fading: drop the old
172
- // leaving body outright (its content is gone from the flow) and
173
- // let the new swap own the choreography.
226
+ // A swap while the previous one is still sliding: drop the old
227
+ // leaving body outright and let the new swap own the stage. (If
228
+ // the previous entering body had not visibly settled yet it now
229
+ // becomes the leaving one and exits from its mid-flight state —
230
+ // one frame of re-staging, matching the classic grammar.)
174
231
  endSwap();
175
232
  const leaving = bodies.value.find((b) => b.phase === "active");
176
233
  if (!leaving) return;
177
234
  // Pre-swap geometry while the leaving body still owns the flow:
178
- // its height is the "old" reference for the ride distance.
179
- const leavingEl = flowRef.value?.querySelector<HTMLElement>(
180
- ".hk-stepflow-body.active",
181
- );
235
+ // its height is the "old" reference for the sheet's delta. The
236
+ // same element also answers the motion probe.
237
+ const leavingEl =
238
+ flowRef.value?.querySelector<HTMLElement>(
239
+ ".hk-stepflow-body.active",
240
+ ) ?? null;
182
241
  const oldH = leavingEl?.offsetHeight ?? 0;
242
+ const durationMs = bodyTransitionMs(leavingEl);
243
+
244
+ if (durationMs <= 0) {
245
+ // Instant settle: no transition is configured (reduced motion,
246
+ // or a stylesheet-less runtime such as a test environment) —
247
+ // swap the bodies atomically so the DOM never carries two
248
+ // steps, then still poke the hosting sheet to remeasure.
249
+ bodies.value = [{ id: mountSeq++, key: next, phase: "active" }];
250
+ await nextTick();
251
+ const newEl = flowRef.value?.querySelector<HTMLElement>(
252
+ ".hk-stepflow-body.active",
253
+ );
254
+ const delta = (newEl?.offsetHeight ?? oldH) - oldH;
255
+ flowRef.value?.dispatchEvent(
256
+ new CustomEvent(STEPFLOW_SWAP_EVENT, {
257
+ bubbles: true,
258
+ detail: { delta },
259
+ }),
260
+ );
261
+ return;
262
+ }
263
+
183
264
  leaving.phase = "leaving";
184
- bodies.value = [
185
- ...bodies.value,
186
- { id: mountSeq++, key: next, phase: "active" },
187
- ];
265
+ const entering: BodyEntry = {
266
+ id: mountSeq++,
267
+ key: next,
268
+ phase: "active",
269
+ };
270
+ // Register the swap BEFORE the reactive mutations so the staged
271
+ // render already sees which entry is entering (hk-stepflow-enter-from) —
272
+ // `swap` itself is not reactive.
273
+ const handle: RunningSwap = {
274
+ leavingId: leaving.id,
275
+ enteringId: entering.id,
276
+ frame: null,
277
+ report: null,
278
+ timer: null,
279
+ listenEl: null,
280
+ onEnded: null,
281
+ };
282
+ swap = handle;
283
+ bodies.value = [...bodies.value, entering];
284
+ swapStage.value = "staged";
188
285
  await nextTick();
189
- // Post-swap geometry: the container's flow height is the new
190
- // body's; the leaving body (already absolute) keeps reporting
191
- // its own height for the delta.
286
+ // Preempted while the DOM patched? The newer swap owns the stage —
287
+ // this continuation must not measure, dispatch or arm anything.
288
+ if (swap !== handle) return;
192
289
  const newEl = flowRef.value?.querySelector<HTMLElement>(
193
290
  ".hk-stepflow-body.active",
194
291
  );
@@ -196,48 +293,45 @@ export default defineComponent({
196
293
  ".hk-stepflow-body.leaving",
197
294
  );
198
295
  if (!newEl || !goneEl) {
296
+ // The flow went away mid-swap (unmount) — restore one body.
297
+ swap = null;
199
298
  leaving.phase = "active";
200
- bodies.value = bodies.value.filter((b) => b.phase === "active");
299
+ bodies.value = [leaving];
300
+ swapStage.value = "idle";
201
301
  return;
202
302
  }
203
- const newH = newEl.offsetHeight;
204
- const delta = newH - oldH;
205
- // Ride distance (px): one body rides the sheet's moving top edge
206
- // by exactly |Δ|; the other stays put. Grow: new rides from +Δ
207
- // (the old top) to 0. Shrink: old rides from −Δ (its original
208
- // top, above the container's new top) to 0.
209
- const mag = Math.abs(delta);
210
- const rides = delta > 0 ? goneEl : newEl;
211
- const from = `${delta > 0 ? mag : -mag}px`;
212
- // Arm the riders: stage the start state with the transition off,
213
- // flush, then flip to the end state under the live transition
214
- // (same dance grammar as the sheet morph — no mid-frame paint of
215
- // the intermediate state).
216
- for (const el of [goneEl, newEl]) {
217
- el.style.transition = "none";
218
- }
219
- goneEl.style.transform = delta > 0 ? "translateY(0px)" : from;
220
- newEl.style.transform = delta > 0 ? from : "translateY(0px)";
221
- goneEl.style.opacity = "1";
222
- newEl.style.opacity = "0";
223
- void flowRef.value?.offsetHeight;
224
- for (const el of [goneEl, newEl]) {
225
- el.style.transition = "";
226
- }
227
- rides.style.transform = "translateY(0px)";
228
- goneEl.style.opacity = "0";
229
- newEl.style.opacity = "1";
303
+ const delta = newEl.offsetHeight - oldH;
230
304
  // Tell the hosting sheet to morph NOW (skip the settle debounce)
231
- // so its clip sweep runs on the same frames as this crossfade.
305
+ // so its clip sweep runs on the same frames as this slide.
232
306
  flowRef.value?.dispatchEvent(
233
307
  new CustomEvent(STEPFLOW_SWAP_EVENT, {
234
308
  bubbles: true,
235
309
  detail: { delta },
236
310
  }),
237
311
  );
238
- const entryId = leaving.id;
239
- const timer = setTimeout(endSwap, 480);
240
- swap = { entryId, timer };
312
+ handle.report = reportTransition(durationMs);
313
+ handle.listenEl = goneEl;
314
+ // Staging frame on the shared bus: commit the staged start states
315
+ // (the forced layout read), then flip BOTH bodies' classes in one
316
+ // patch — the leave and enter transitions start on the very same
317
+ // style recalculation.
318
+ handle.frame = scheduleFrame(() => {
319
+ if (swap !== handle) return;
320
+ handle.frame = null;
321
+ void flowRef.value?.offsetHeight;
322
+ const onEnded = (event: TransitionEvent) => {
323
+ // transitionend bubbles — only the leaving body's own
324
+ // property transitions may settle the swap.
325
+ if (event.target === goneEl) endSwap();
326
+ };
327
+ goneEl.addEventListener("transitionend", onEnded);
328
+ handle.onEnded = onEnded;
329
+ handle.timer = setTimeout(
330
+ endSwap,
331
+ durationMs + SWAP_WATCHDOG_GRACE_MS,
332
+ );
333
+ swapStage.value = "running";
334
+ });
241
335
  },
242
336
  { flush: "post" },
243
337
  );
@@ -248,7 +342,7 @@ export default defineComponent({
248
342
  });
249
343
 
250
344
  onBeforeUnmount(() => {
251
- if (swap) clearTimeout(swap.timer);
345
+ endSwap();
252
346
  });
253
347
 
254
348
  return () => {
@@ -268,11 +362,20 @@ export default defineComponent({
268
362
  data-strategy={props.stickyHeader ? pinStrategy.value : undefined}
269
363
  />
270
364
  )}
271
- <div class="hk-stepflow-bodies">
365
+ <div class="hk-stepflow-bodies" data-direction={dir}>
272
366
  {bodies.value.map((entry) => (
273
367
  <div
274
368
  key={entry.id}
275
- class={["hk-stepflow-body", entry.phase]}
369
+ class={[
370
+ "hk-stepflow-body",
371
+ entry.phase,
372
+ entry.id === swap?.enteringId && swapStage.value === "staged"
373
+ ? "hk-stepflow-enter-from"
374
+ : null,
375
+ entry.id === swap?.leavingId && swapStage.value === "running"
376
+ ? "hk-stepflow-leave-to"
377
+ : null,
378
+ ]}
276
379
  >
277
380
  {slots[entry.key]?.({
278
381
  key: entry.key,
@@ -1,61 +0,0 @@
1
- /**
2
- * Source contract for the stepflow crossfade (2026-09-21 user spec,
3
- * round 7): a swap renders BOTH bodies for the whole duration — the
4
- * entering one in the flow, the leaving one absolutely positioned — and
5
- * the ride + fade share one duration/ease pair (0.3s family default) so
6
- * the sheet's clip sweep, the ride and the crossfade land together.
7
- *
8
- * Pinned here so a refactor cannot silently regress to the retired
9
- * out-in slide (hk-stepflow-fwd/back classes) or strand a phone-only
10
- * stand-down: the crossfade is the ONE grammar, on every viewport.
11
- */
12
- import { describe, expect, it } from "vitest";
13
- import { readFileSync } from "node:fs";
14
- import { dirname, join } from "node:path";
15
- import { fileURLToPath } from "node:url";
16
-
17
- const here = dirname(fileURLToPath(import.meta.url));
18
- const src = readFileSync(join(here, "HkStepFlow.scss"), "utf-8");
19
-
20
- describe("HkStepFlow crossfade contract", () => {
21
- it("rides and fades on one shared duration/ease pair", () => {
22
- const rule = src.match(/\.hk-stepflow-body\s*\{[^}]*\}/)![0]!;
23
- const transitions = rule.match(/transition:\s*[^;}]+/g) ?? [];
24
- expect(transitions).toHaveLength(1);
25
- const decl = transitions[0]!;
26
- expect(decl).toContain("opacity");
27
- expect(decl).toContain("transform");
28
- expect(decl).toContain("var(--hk-stepflow-duration, 0.3s)");
29
- });
30
-
31
- it("defaults the family duration to 0.3s", () => {
32
- const root = src.match(/\.hk-step-flow\s*\{[^}]*\}/)![0]!;
33
- expect(root).toContain("--hk-stepflow-duration: 0.3s;");
34
- });
35
-
36
- it("keeps the leaving body out of the flow and pointer-transparent", () => {
37
- const rule = src.match(/\.hk-stepflow-body\.leaving\s*\{[^}]*\}/)![0]!;
38
- expect(rule).toContain("position: absolute");
39
- expect(rule).toContain("pointer-events: none");
40
- });
41
-
42
- it("dispatches the swap passthrough so the host sheet morphs in lockstep", () => {
43
- // The component side of the lockstep contract: the swap dispatches
44
- // the bubbling event the moment the entering body owns the flow
45
- // height, and the modal side short-circuits its settle debounce.
46
- const tsx = readFileSync(join(here, "HkStepFlow.tsx"), "utf-8");
47
- expect(tsx).toContain("STEPFLOW_SWAP_EVENT");
48
- expect(tsx).toMatch(/new CustomEvent\(STEPFLOW_SWAP_EVENT/);
49
- const modal = readFileSync(join(here, "HkModal.tsx"), "utf-8");
50
- expect(modal).toContain('addEventListener(STEPFLOW_SWAP_EVENT');
51
- expect(modal).toMatch(/removeEventListener\(STEPFLOW_SWAP_EVENT/);
52
- });
53
-
54
- it("retires the out-in slide classes and every viewport stand-down", () => {
55
- // The retired grammar must not sneak back in, and the crossfade is
56
- // unconditional — no media block may fork step-body behaviour.
57
- expect(src).not.toContain("hk-stepflow-fwd");
58
- expect(src).not.toContain("hk-stepflow-back");
59
- expect(src).not.toMatch(/@media[^{]*max-width/);
60
- });
61
- });