@celestia-island/hikari 0.55.40 → 0.55.41

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.40",
3
+ "version": "0.55.41",
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",
@@ -475,8 +475,13 @@
475
475
  // clip-path sweep (the only transition left on phones).
476
476
  //
477
477
  // Desktop keeps the full base list (height + max-height + clip-path).
478
+ // 0.3s = the stepflow crossfade's family standard (2026-09-21 user
479
+ // spec, round 7): the clip sweep, the riding body and the fade must
480
+ // land together. Keyboard-driven height changes never animate
481
+ // through this list (height/max-height are not transitioned), so
482
+ // only content morphs ride this duration.
478
483
  transition:
479
- clip-path var(--duration-fast, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1));
484
+ clip-path var(--hk-modal-morph-duration, 0.3s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1));
480
485
  // !important beats the inline maxWidth (width prop); base width: 100%
481
486
  // already applies at every viewport, so only the cap is re-asserted.
482
487
  max-width: 100% !important;
@@ -121,3 +121,15 @@ describe("HkModal mobile sheet spacing contract", () => {
121
121
  });
122
122
  });
123
123
  });
124
+
125
+ // 2026-09-21 user spec, round 7: the phone sheet's clip sweep shares the
126
+ // stepflow crossfade's 0.3s family duration (the pair must land together).
127
+ describe("HkModal phone morph duration contract", () => {
128
+ it("rides the clip sweep on --hk-modal-morph-duration (0.3s default)", () => {
129
+ const block = src.slice(src.indexOf("@media (max-width: 767px)"));
130
+ const content = block.match(/\.hk-modal-content\s*{[^}]*}/)?.[0] ?? "";
131
+ expect(content).toMatch(
132
+ /clip-path var\(--hk-modal-morph-duration, 0\.3s\) var\(--ease-standard, cubic-bezier\(0\.4, 0, 0\.2, 1\)\)/,
133
+ );
134
+ });
135
+ });
@@ -63,6 +63,16 @@ describe("sheet family morph-performance contract", () => {
63
63
  );
64
64
  });
65
65
 
66
+ it("slows the phone sheet's clip sweep to the crossfade's 0.3s", () => {
67
+ // 2026-09-21 user spec, round 7: the clip sweep, the riding body and
68
+ // the fade must land together — the phone block overrides the fast
69
+ // desktop timing with the stepflow family standard. Desktop keeps
70
+ // the fast pair above.
71
+ expect(mobileModal).toMatch(
72
+ /^[ \t]*clip-path var\(--hk-modal-morph-duration, 0\.3s\) var\(--ease-standard, cubic-bezier\(0\.4, 0, 0\.2, 1\)\);$/m,
73
+ );
74
+ });
75
+
66
76
  it("never backdrop-filters the docked modal sheet or its scrim", () => {
67
77
  expect(mobileModal).toMatch(
68
78
  /^[ \t]*backdrop-filter:[ \t]*var\(--hk-modal-blur-mobile, none\);$/m,
@@ -1,15 +1,4 @@
1
- import {
2
- computed,
3
- defineComponent,
4
- nextTick,
5
- onBeforeUnmount,
6
- onMounted,
7
- ref,
8
- shallowRef,
9
- Teleport,
10
- watch,
11
- type PropType,
12
- } from "vue";
1
+ import { computed, defineComponent, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, Teleport, watch, type PropType, } from "vue";
13
2
 
14
3
  import { useI18n } from "../i18n/context";
15
4
  import "./HkModal.scss";
@@ -23,6 +12,8 @@ import { useSurfaceTransition } from "../composables/useSurfaceTransition";
23
12
  import { useSurfaceMachine } from "../composables/useSurfaceMachine";
24
13
  import { useSurfaceContentHold } from "../composables/useSurfaceContentHold";
25
14
  import { useSizeMorph } from "../composables/useSizeMorph";
15
+
16
+ import { STEPFLOW_SWAP_EVENT } from "./HkStepFlow";
26
17
  import HButton from "./HkButton";
27
18
  import HFab from "./HkFab";
28
19
  import HSpinner from "./HkSpinner";
@@ -218,6 +209,22 @@ export default defineComponent({
218
209
  let previouslyFocused: HTMLElement | null = null;
219
210
  let unmounted = false;
220
211
 
212
+ // HkStepFlow swaps dispatch STEPFLOW_SWAP_EVENT (bubbling) from the
213
+ // flow root the moment the entering body owns the flow height. The
214
+ // morph's settle debounce is tuned for streaming bursts, but a step
215
+ // swap is one clean change whose sheet sweep must start on the SAME
216
+ // frames as the crossfade (2026-09-21 user spec, round 7) — so the
217
+ // event short-circuits straight into remeasure().
218
+ const onStepflowSwap = (): void => {
219
+ if (machine.phase.value === "open") morph.remeasure();
220
+ };
221
+ onMounted(() => {
222
+ bodyRef.value?.addEventListener(STEPFLOW_SWAP_EVENT, onStepflowSwap);
223
+ });
224
+ onBeforeUnmount(() => {
225
+ bodyRef.value?.removeEventListener(STEPFLOW_SWAP_EVENT, onStepflowSwap);
226
+ });
227
+
221
228
  const overlayZ = computed(() => handle.value?.zIndex ?? 0);
222
229
  const contentZ = computed(() => (handle.value?.zIndex ?? 0) + 1);
223
230
  const resolvedWidth = computed(() =>
@@ -0,0 +1,61 @@
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
+ });
@@ -1,17 +1,23 @@
1
- // Direction-aware step body transitions for HkStepFlow.
1
+ // Crossfading step bodies for HkStepFlow.
2
+ //
3
+ // A swap renders BOTH bodies for the whole duration: the entering one in
4
+ // the flow (so the container's height — and the hosting sheet's morph —
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.
2
10
  //
3
- // Forward slides the body leftward (the flow advances to a later step),
4
- // back mirrors it toward the right. Timings follow the phase-transition
5
- // family — an expressive ease-out on enter, a sharp ease-in on leave.
6
11
  // Duration resolves from one local custom property so consumers can tune
7
- // every phase at once.
12
+ // every phase at once; the default is the 0.3s family standard the spec
13
+ // asked for.
8
14
 
9
15
  .hk-step-flow {
10
- --hk-stepflow-duration: 0.2s;
16
+ --hk-stepflow-duration: 0.3s;
11
17
  }
12
18
 
13
- // Breathing room between the step indicator and the step body. Without it
14
- // the timeline row butts directly against the first body element (modal
19
+ // Breathing room between the step indicator and the step bodies. Without
20
+ // it the timeline row butts directly against the first body element (modal
15
21
  // wizards read as cramped). Tune or remove per host through
16
22
  // --hk-stepflow-header-gap; the sticky mode folds the gap into the pinned
17
23
  // header's own padding so the surface tint stays continuous while the
@@ -23,10 +29,10 @@
23
29
  // Sticky header mode: positioning, surface and the whitespace contract
24
30
  // live on the shared .hk-scroll-pin rules (HkScrollPin.scss) — the
25
31
  // timeline root carries the class plus data-side/data-strategy. This
26
- // block keeps only what is step-flow-specific: the body gap folded into
32
+ // block keeps only what's step-flow-specific: the body gap folded into
27
33
  // the pinned header's own padding (so the tint stays continuous while
28
- // the body scrolls underneath) and the legacy styling knobs mapped onto
29
- // the pin's custom properties. The doubled class selector must beat
34
+ // the body scrolls) and the legacy styling knobs mapped onto the pin's
35
+ // custom properties. The doubled class selector must beat
30
36
  // .hk-scroll-pin's own declarations regardless of import order.
31
37
  .hk-step-flow[data-sticky-header] > .hk-timeline.hk-scroll-pin {
32
38
  --hk-scroll-pin-z: var(--hk-stepflow-sticky-z, 10);
@@ -42,76 +48,33 @@
42
48
  padding-bottom: var(--hk-stepflow-header-gap, var(--space-16, 1rem));
43
49
  }
44
50
 
45
- .hk-stepflow-fwd-enter-active,
46
- .hk-stepflow-back-enter-active {
47
- transition:
48
- opacity var(--hk-stepflow-duration, 0.2s) cubic-bezier(0.16, 1, 0.3, 1),
49
- transform var(--hk-stepflow-duration, 0.2s) cubic-bezier(0.16, 1, 0.3, 1);
51
+ // 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.
53
+ .hk-stepflow-bodies {
54
+ position: relative;
50
55
  }
51
56
 
52
- .hk-stepflow-fwd-leave-active,
53
- .hk-stepflow-back-leave-active {
57
+ .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.
54
61
  transition:
55
- opacity var(--hk-stepflow-duration, 0.2s) cubic-bezier(0.5, 0, 0.75, 0),
56
- transform var(--hk-stepflow-duration, 0.2s) cubic-bezier(0.5, 0, 0.75, 0);
57
- }
58
-
59
- .hk-stepflow-fwd-enter-from {
60
- opacity: 0;
61
- transform: translateX(24px);
62
- }
63
-
64
- .hk-stepflow-fwd-leave-to {
65
- opacity: 0;
66
- transform: translateX(-24px);
67
- }
68
-
69
- .hk-stepflow-back-enter-from {
70
- opacity: 0;
71
- transform: translateX(-24px);
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));
72
64
  }
73
65
 
74
- .hk-stepflow-back-leave-to {
75
- opacity: 0;
76
- transform: translateX(24px);
77
- }
78
-
79
- /* RTL: reading direction flips, so the slide mirrors — forward enters
80
- * from the left and exits right (house pattern: HkListTransition.scss
81
- * [dir="rtl"] form). */
82
- [dir="rtl"] .hk-stepflow-fwd-enter-from,
83
- [dir="rtl"] .hk-stepflow-back-leave-to {
84
- transform: translateX(-24px);
85
- }
86
-
87
- [dir="rtl"] .hk-stepflow-fwd-leave-to,
88
- [dir="rtl"] .hk-stepflow-back-enter-from {
89
- transform: translateX(24px);
90
- }
91
-
92
- /* Phone (≤767px): the body snaps between steps instead of sliding. On
93
- * the mobile sheet the frame's own size morph already carries the
94
- * spatial feedback, and the slide's transient layer promotion/demotion
95
- * costs a one-frame raster gap at both ends on phone GPUs — the
96
- * "content blinks once on every step" report (2026-09-21 chest field
97
- * report, AddProviderWizard Prev/Next). Same shape as the
98
- * reduced-motion block below; desktop keeps the directional slide. */
99
- @media (max-width: 767px) {
100
- .hk-stepflow-fwd-enter-active,
101
- .hk-stepflow-back-enter-active,
102
- .hk-stepflow-fwd-leave-active,
103
- .hk-stepflow-back-leave-active {
104
- transition: none;
105
- }
66
+ .hk-stepflow-body.leaving {
67
+ position: absolute;
68
+ top: 0;
69
+ left: 0;
70
+ right: 0;
71
+ pointer-events: none;
106
72
  }
107
73
 
108
- /* Reduced motion: the body snaps between steps instead of sliding
109
- * (house pattern: HkExpansionPanel.scss / HkAffixPicker.scss). */
74
+ /* Reduced motion: bodies swap instantly — no ride, no fade (the sheet
75
+ * morph collapses the same way through its own duration tokens). */
110
76
  @media (prefers-reduced-motion: reduce) {
111
- .hk-stepflow-fwd-enter-active,
112
- .hk-stepflow-back-enter-active,
113
- .hk-stepflow-fwd-leave-active,
114
- .hk-stepflow-back-leave-active {
77
+ .hk-stepflow-body {
115
78
  transition: none;
116
79
  }
117
80
  }
@@ -68,11 +68,16 @@ function mountStepFlow(options: {
68
68
  return { container, setCurrent: (key) => { current.value = key; } };
69
69
  }
70
70
 
71
- /** Let Vue finish the out-in cycle's settling timers before teardown. */
71
+ /** Let the swap's choreography timers (480ms sweep cleanup) settle. */
72
72
  async function settle(): Promise<void> {
73
73
  await new Promise((resolve) => setTimeout(resolve, 80));
74
74
  }
75
75
 
76
+ /** Outlive the 480ms leaving-body cleanup timer. */
77
+ async function outliveSwap(): Promise<void> {
78
+ await new Promise((resolve) => setTimeout(resolve, 540));
79
+ }
80
+
76
81
  afterEach(() => {
77
82
  for (const app of mounts.splice(0)) app.unmount();
78
83
  for (const el of containers.splice(0)) el.remove();
@@ -105,25 +110,43 @@ describe("HkStepFlow", () => {
105
110
  await nextTick();
106
111
  await nextTick();
107
112
  await new Promise((resolve) => setTimeout(resolve, 60));
108
- expect(t.container.querySelector(".hk-stepflow-body")?.textContent).toBe("d-body");
109
- await settle();
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.
117
+ const bodies = t.container.querySelectorAll(".hk-stepflow-body");
118
+ expect(bodies.length).toBe(1);
119
+ expect(bodies[0]!.className).toBe("hk-stepflow-body active");
120
+ expect(bodies[0]!.textContent).toBe("d-body");
110
121
  });
111
122
 
112
- it("slides forward when moving to a later step and back when returning", async () => {
113
- const t = mountStepFlow({ initial: "b" });
114
- const bodyClass = (): string =>
115
- t.container.querySelector(".hk-stepflow-body")?.className ?? "";
116
-
117
- t.setCurrent("c");
118
- await nextTick();
119
- // out-in: the old body is mid-leave on this tick.
120
- expect(bodyClass()).toContain("hk-stepflow-fwd-leave-active");
121
-
122
- await settle();
123
+ it("crossfades both bodies for the whole swap window (2026-09-21 spec)", async () => {
124
+ const t = mountStepFlow({ initial: "a" });
123
125
  t.setCurrent("b");
124
126
  await nextTick();
125
- expect(bodyClass()).toContain("hk-stepflow-back-leave-active");
126
- await settle();
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.
141
+ 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();
149
+ expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(1);
127
150
  });
128
151
 
129
152
  it("passes key/index/direction to scoped slots across navigation", async () => {
@@ -177,7 +200,7 @@ describe("HkStepFlow", () => {
177
200
  await nextTick();
178
201
  await settle();
179
202
  expect(selected).toEqual(["a"]);
180
- expect(container.querySelector(".hk-stepflow-body")?.textContent).toBe("");
203
+ expect(container.querySelector(".hk-stepflow-body.active")?.textContent).toBe("");
181
204
  await settle();
182
205
  });
183
206
 
@@ -188,7 +211,7 @@ describe("HkStepFlow", () => {
188
211
  t.setCurrent("does-not-exist");
189
212
  await nextTick();
190
213
  await settle();
191
- const body = t.container.querySelector(".hk-stepflow-body");
214
+ const body = t.container.querySelector(".hk-stepflow-body.active");
192
215
  expect(body).not.toBeNull();
193
216
  expect(body?.textContent).toBe("");
194
217
  expect(warnSpy.mock.calls.some((args) => String(args[0]).includes("Slot"))).toBe(false);
@@ -1,4 +1,12 @@
1
- import { defineComponent, Transition, onMounted, ref, watch, type PropType } from "vue";
1
+ import {
2
+ defineComponent,
3
+ nextTick,
4
+ onBeforeUnmount,
5
+ onMounted,
6
+ ref,
7
+ watch,
8
+ type PropType,
9
+ } from "vue";
2
10
 
3
11
  import HkTimeline from "./HkTimeline";
4
12
  import type { TimelineCollapse, TimelineStep } from "./HkTimeline";
@@ -20,12 +28,51 @@ export interface StepFlowSlotProps {
20
28
  direction: "forward" | "back";
21
29
  }
22
30
 
31
+ /** One rendered body: the active step plus, during a swap, the leaving one. */
32
+ interface BodyEntry {
33
+ /** Unique mount id — the v-for key, so a re-entering step remounts. */
34
+ id: number;
35
+ /** The step key this body renders. */
36
+ key: string;
37
+ /** `active` bodies sit in the flow; a `leaving` body is out of flow
38
+ * (absolutely positioned) for the crossfade's duration. */
39
+ phase: "active" | "leaving";
40
+ }
41
+
42
+ /** A swap whose leaving body was handed its end state (transition armed). */
43
+ interface RunningSwap {
44
+ entryId: number;
45
+ timer: ReturnType<typeof setTimeout>;
46
+ }
47
+
48
+ export const STEPFLOW_SWAP_EVENT = "hk-stepflow-swap";
49
+
23
50
  /**
24
51
  * Generic step-flow container: an optional HkTimeline header bound to
25
- * `modelValue` plus a direction-aware sliding body fed purely by named
26
- * slots keyed by step key. Navigation state lives in the consumer — the
27
- * component only translates `modelValue` changes into `out-in` body
28
- * transitions and echoes timeline selections upward.
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.
56
+ *
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.
71
+ *
72
+ * Each swap also dispatches `hk-stepflow-swap` (bubbles, detail
73
+ * `{ delta }`) from the flow root — the hosting modal listens for it and
74
+ * re-measures immediately, skipping the morph's settle debounce so the
75
+ * sheet's clip sweep starts on the same frame as the crossfade.
29
76
  */
30
77
  export default defineComponent({
31
78
  name: "HkStepFlow",
@@ -89,6 +136,22 @@ export default defineComponent({
89
136
  },
90
137
  );
91
138
 
139
+ // ── Crossfade body bookkeeping ────────────────────────────────────
140
+ let mountSeq = 0;
141
+ const bodies = ref<BodyEntry[]>([
142
+ { id: mountSeq++, key: props.modelValue, phase: "active" },
143
+ ]);
144
+ let swap: RunningSwap | null = null;
145
+ const flowRef = ref<HTMLDivElement | null>(null);
146
+
147
+ function endSwap(): void {
148
+ if (!swap) return;
149
+ clearTimeout(swap.timer);
150
+ const id = swap.entryId;
151
+ swap = null;
152
+ bodies.value = bodies.value.filter((b) => b.id !== id);
153
+ }
154
+
92
155
  // Sticky-header whitespace strategy (2026-09-14): the timeline is the
93
156
  // flow's boundary element, but content may sit ABOVE the whole flow
94
157
  // inside the same scroll body — a bleed pin's negative margin would
@@ -99,27 +162,100 @@ export default defineComponent({
99
162
  // on mount (attribute scan only — no geometry), so CSR surfaces see
100
163
  // the attribute flip once right after hydration; hikari renders
101
164
  // client-side only.
102
- const flowRef = ref<HTMLDivElement | null>(null);
103
165
  const pinStrategy = ref<"offset" | "bleed">("bleed");
104
166
 
167
+ watch(
168
+ () => props.modelValue,
169
+ async (next, prev) => {
170
+ 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.
174
+ endSwap();
175
+ const leaving = bodies.value.find((b) => b.phase === "active");
176
+ if (!leaving) return;
177
+ // 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
+ );
182
+ const oldH = leavingEl?.offsetHeight ?? 0;
183
+ leaving.phase = "leaving";
184
+ bodies.value = [
185
+ ...bodies.value,
186
+ { id: mountSeq++, key: next, phase: "active" },
187
+ ];
188
+ 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.
192
+ const newEl = flowRef.value?.querySelector<HTMLElement>(
193
+ ".hk-stepflow-body.active",
194
+ );
195
+ const goneEl = flowRef.value?.querySelector<HTMLElement>(
196
+ ".hk-stepflow-body.leaving",
197
+ );
198
+ if (!newEl || !goneEl) {
199
+ leaving.phase = "active";
200
+ bodies.value = bodies.value.filter((b) => b.phase === "active");
201
+ return;
202
+ }
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";
230
+ // Tell the hosting sheet to morph NOW (skip the settle debounce)
231
+ // so its clip sweep runs on the same frames as this crossfade.
232
+ flowRef.value?.dispatchEvent(
233
+ new CustomEvent(STEPFLOW_SWAP_EVENT, {
234
+ bubbles: true,
235
+ detail: { delta },
236
+ }),
237
+ );
238
+ const entryId = leaving.id;
239
+ const timer = setTimeout(endSwap, 480);
240
+ swap = { entryId, timer };
241
+ },
242
+ { flush: "post" },
243
+ );
244
+
105
245
  onMounted(() => {
106
246
  const host = flowRef.value?.closest(`.${SCROLL_HOST_CLASS}`);
107
247
  if (host?.hasAttribute("data-pad-cover")) pinStrategy.value = "offset";
108
248
  });
109
249
 
250
+ onBeforeUnmount(() => {
251
+ if (swap) clearTimeout(swap.timer);
252
+ });
253
+
110
254
  return () => {
111
- const index = indexOf(props.modelValue);
112
- // An unknown key finds no slot: the body simply renders empty, no
113
- // warnings — consumers may briefly park between two known steps.
114
- const slotFn = index >= 0 ? slots[props.modelValue] : undefined;
115
255
  const dir = direction.value;
116
256
 
117
257
  return (
118
- <div
119
- ref={flowRef}
120
- class="hk-step-flow"
121
- data-sticky-header={props.stickyHeader || undefined}
122
- >
258
+ <div ref={flowRef} class="hk-step-flow" data-sticky-header={props.stickyHeader || undefined}>
123
259
  {!props.hideTimeline && (
124
260
  <HkTimeline
125
261
  steps={props.steps}
@@ -132,14 +268,20 @@ export default defineComponent({
132
268
  data-strategy={props.stickyHeader ? pinStrategy.value : undefined}
133
269
  />
134
270
  )}
135
- <Transition
136
- name={dir === "back" ? "hk-stepflow-back" : "hk-stepflow-fwd"}
137
- mode="out-in"
138
- >
139
- <div key={props.modelValue} class="hk-stepflow-body">
140
- {slotFn?.({ key: props.modelValue, index, direction: dir })}
141
- </div>
142
- </Transition>
271
+ <div class="hk-stepflow-bodies">
272
+ {bodies.value.map((entry) => (
273
+ <div
274
+ key={entry.id}
275
+ class={["hk-stepflow-body", entry.phase]}
276
+ >
277
+ {slots[entry.key]?.({
278
+ key: entry.key,
279
+ index: indexOf(entry.key),
280
+ direction: dir,
281
+ })}
282
+ </div>
283
+ ))}
284
+ </div>
143
285
  </div>
144
286
  );
145
287
  };
@@ -1,105 +0,0 @@
1
- /**
2
- * Source contract for the phone step-swap snap (2026-09-21 chest field
3
- * report, AddProviderWizard Prev/Next "content blinks once").
4
- *
5
- * On ≤767px the step body's slide+fade must be inert: inside the mobile
6
- * bottom sheet the frame's own size morph already carries the spatial
7
- * feedback, and the slide's transient layer promotion/demotion costs a
8
- * one-frame raster gap at both ends on phone GPUs. Desktop keeps the
9
- * directional slide; reduced motion already snaps globally.
10
- *
11
- * Pinned here so a refactor cannot silently restore the phone slide —
12
- * the class names are the same, so only the media-scoped rule proves it.
13
- */
14
- import { describe, expect, it } from "vitest";
15
- import { readFileSync } from "node:fs";
16
- import { dirname, join } from "node:path";
17
- import { fileURLToPath } from "node:url";
18
-
19
- const here = dirname(fileURLToPath(import.meta.url));
20
- const src = readFileSync(join(here, "HkStepFlow.scss"), "utf-8");
21
-
22
- /** Brace-aware `@media <query>` block extractor: a naive slice at the
23
- * first `}` would cut at the first nested rule's closing brace. */
24
- function mediaBlocks(source: string, query: string): string[] {
25
- const blocks: string[] = [];
26
- let from = 0;
27
- for (;;) {
28
- const at = source.indexOf(`@media ${query}`, from);
29
- if (at < 0) break;
30
- const open = source.indexOf("{", at);
31
- let depth = 0;
32
- let i = open;
33
- for (; i < source.length; i++) {
34
- if (source[i] === "{") depth++;
35
- else if (source[i] === "}") {
36
- depth--;
37
- if (depth === 0) break;
38
- }
39
- }
40
- blocks.push(source.slice(at, i + 1));
41
- from = i + 1;
42
- }
43
- return blocks;
44
- }
45
-
46
- const PHONE_QUERY = "(max-width: 767px)";
47
- /** The four transition-class selectors Vue's <Transition> toggles. */
48
- const ACTIVE_SELECTORS = [
49
- ".hk-stepflow-fwd-enter-active",
50
- ".hk-stepflow-back-enter-active",
51
- ".hk-stepflow-fwd-leave-active",
52
- ".hk-stepflow-back-leave-active",
53
- ];
54
-
55
- /** The single ≤767px block, asserted present IN the test that needs it
56
- * (a beforeAll assertion would downgrade a missing block to "skipped"
57
- * instead of a hard failure). */
58
- function phoneBlock(): string {
59
- const blocks = mediaBlocks(src, PHONE_QUERY);
60
- expect(blocks.length).toBe(1);
61
- return blocks[0]!;
62
- }
63
-
64
- describe("HkStepFlow phone snap contract", () => {
65
- it("disables the slide/fade transition on ≤767px", () => {
66
- const block = phoneBlock();
67
- for (const selector of ACTIVE_SELECTORS) {
68
- expect(block).toContain(selector);
69
- }
70
- // transition:none on every active class — the whole point: no
71
- // property animates, so no promotion window exists to blink.
72
- expect(block).toMatch(/transition:\s*none\s*;/);
73
- // …and it must be the ONLY transition declaration in the block
74
- // (a stray `transition: opacity …` would re-open the blink).
75
- const declarations = block.match(/transition:\s*[^;]+;/g) ?? [];
76
- expect(declarations).toHaveLength(1);
77
- });
78
-
79
- it("keeps the desktop slide outside the phone block", () => {
80
- // Mutation guard: the phone rule must be ADDITIVE. The base
81
- // (desktop) enter/leave rules still carry the opacity+transform
82
- // transition, so deleting them fails here instead of passing
83
- // vacuously.
84
- const baseEnter = src.match(
85
- /\.hk-stepflow-fwd-enter-active\s*,\s*\n\.hk-stepflow-back-enter-active\s*\{[^}]*\}/,
86
- );
87
- expect(baseEnter).not.toBeNull();
88
- expect(baseEnter![0]).toContain("transition:");
89
- expect(baseEnter![0]).toContain("opacity");
90
- expect(baseEnter![0]).toContain("transform");
91
-
92
- const baseLeave = src.match(
93
- /\.hk-stepflow-fwd-leave-active\s*,\s*\n\.hk-stepflow-back-leave-active\s*\{[^}]*\}/,
94
- );
95
- expect(baseLeave).not.toBeNull();
96
- expect(baseLeave![0]).toContain("opacity");
97
- expect(baseLeave![0]).toContain("transform");
98
- });
99
-
100
- it("keeps the reduce-motion snap block (house pattern) intact", () => {
101
- const blocks = mediaBlocks(src, "(prefers-reduced-motion: reduce)");
102
- expect(blocks.length).toBe(1);
103
- expect(blocks[0]).toMatch(/transition:\s*none\s*;/);
104
- });
105
- });