@celestia-island/hikari 0.55.45 → 0.55.46

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.45",
3
+ "version": "0.55.46",
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",
@@ -0,0 +1,329 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, defineComponent, h, nextTick, ref } from "vue";
3
+
4
+ import HkModal from "./HkModal";
5
+ import {
6
+ SHEET_SWEEP_SETTLE_EVENT,
7
+ SHEET_SWEEP_STAGE_EVENT,
8
+ STEPFLOW_SWAP_EVENT,
9
+ } from "./HkStepFlow";
10
+
11
+ /**
12
+ * Behavioural contract for the stepflow → sheet morph handoff
13
+ * (2026-09-22 user directive, round 10): a HkStepFlow swap announces the
14
+ * phase that owns the height change (`{ delta, durationMs, phase }`) and
15
+ * the hosting modal must morph its clip over ONE phase, not the 0.3s
16
+ * family default.
17
+ *
18
+ * Round-1 verification (2026-09-22) found this wiring DEAD in the real
19
+ * consumer: the listener was attached in `onMounted` while the modal
20
+ * renders nothing until `machine.mounted` flips, so `bodyRef.value` was
21
+ * undefined at attach time and every mount-closed-then-open consumer
22
+ * (chest's wizards) silently got no handoff at all — the sheet fell back
23
+ * to the morph's 150ms settle debounce plus a 0.3s sweep. These tests
24
+ * therefore drive the REAL pattern (mount closed, open later) and assert
25
+ * the observable token, so the defect cannot return unnoticed.
26
+ *
27
+ * happy-dom has no transition engine: durations are stubbed and rAF is
28
+ * frozen (same harness as HkModal.contenthold.test.tsx), with fake timers
29
+ * driving the machine's phase edges.
30
+ */
31
+ const mounts: ReturnType<typeof createApp>[] = [];
32
+ const containers: HTMLElement[] = [];
33
+
34
+ afterEach(() => {
35
+ for (const app of mounts.splice(0)) app.unmount();
36
+ for (const el of containers.splice(0)) el.remove();
37
+ vi.unstubAllGlobals();
38
+ vi.useRealTimers();
39
+ });
40
+
41
+ function freezeRaf(): void {
42
+ vi.stubGlobal("requestAnimationFrame", (_cb: FrameRequestCallback) => 0 as unknown as number);
43
+ vi.stubGlobal("cancelAnimationFrame", () => {});
44
+ }
45
+
46
+ function stubDurations(): void {
47
+ const realGCS = window.getComputedStyle.bind(window);
48
+ vi.stubGlobal("getComputedStyle", (el: Element, ...rest: unknown[]) => {
49
+ const style = realGCS(el as Element, ...(rest as []));
50
+ return { ...style, transitionDuration: "0.3s" } as CSSStyleDeclaration;
51
+ });
52
+ }
53
+
54
+ interface Rig {
55
+ open: { value: boolean };
56
+ bodyEl: () => HTMLElement | null;
57
+ frameEl: () => HTMLElement | null;
58
+ /** Fire the swap passthrough the way HkStepFlow does (bubbling from a
59
+ * descendant of the modal body). */
60
+ announceSwap: (detail: { delta: number; durationMs: number; phase: string }) => void;
61
+ morphToken: () => string;
62
+ }
63
+
64
+ async function mountRig(): Promise<Rig> {
65
+ const container = document.createElement("div");
66
+ document.body.appendChild(container);
67
+ containers.push(container);
68
+
69
+ const open = ref(false);
70
+ const Wrapper = defineComponent({
71
+ setup() {
72
+ return () =>
73
+ h(HkModal, {
74
+ modelValue: open.value,
75
+ closable: true,
76
+ title: "swap-test",
77
+ "onUpdate:modelValue": (v: boolean) => { open.value = v; },
78
+ }, {
79
+ default: () => h("div", { class: "swap-body" }, "step content"),
80
+ });
81
+ },
82
+ });
83
+ const app = createApp(Wrapper);
84
+ mounts.push(app);
85
+ app.mount(container);
86
+ await nextTick();
87
+
88
+ return {
89
+ open,
90
+ bodyEl: () => document.querySelector<HTMLElement>(".hk-modal-body"),
91
+ frameEl: () => document.querySelector<HTMLElement>(".hk-modal-content"),
92
+ announceSwap: (detail) => {
93
+ const host = document.querySelector<HTMLElement>(".hk-modal-body .swap-body")
94
+ ?? document.querySelector<HTMLElement>(".hk-modal-body");
95
+ if (!host) throw new Error("announceSwap: modal body missing");
96
+ host.dispatchEvent(
97
+ new CustomEvent(STEPFLOW_SWAP_EVENT, { bubbles: true, detail }),
98
+ );
99
+ },
100
+ morphToken: () =>
101
+ document
102
+ .querySelector<HTMLElement>(".hk-modal-content")
103
+ ?.style.getPropertyValue("--hk-modal-morph-duration") ?? "",
104
+ };
105
+ }
106
+
107
+ describe("HkModal stepflow swap handoff", () => {
108
+ it("morphs over the announced phase for a modal mounted CLOSED (the real consumer path)", async () => {
109
+ vi.useFakeTimers();
110
+ freezeRaf();
111
+ stubDurations();
112
+ const rig = await mountRig();
113
+
114
+ // The premise of the round-1 defect: while closed there is no body to
115
+ // attach to, so an onMounted-time attach could never have worked.
116
+ expect(rig.bodyEl()).toBeNull();
117
+
118
+ rig.open.value = true;
119
+ await vi.advanceTimersByTimeAsync(600);
120
+ expect(rig.bodyEl()).not.toBeNull();
121
+ expect(rig.morphToken()).toBe("");
122
+
123
+ // A swap that owns the height in its FIRST phase.
124
+ rig.announceSwap({ delta: 120, durationMs: 150, phase: "exit" });
125
+ await nextTick();
126
+ expect(rig.morphToken()).toBe("150ms");
127
+
128
+ // …and the override is scoped to that one sweep window.
129
+ await vi.advanceTimersByTimeAsync(500);
130
+ expect(rig.morphToken()).toBe("");
131
+ });
132
+
133
+ it("leaves the stylesheet default in place for an instant settlement", async () => {
134
+ vi.useFakeTimers();
135
+ freezeRaf();
136
+ stubDurations();
137
+ const rig = await mountRig();
138
+ rig.open.value = true;
139
+ await vi.advanceTimersByTimeAsync(600);
140
+
141
+ // Reduced motion / stylesheet-less runtimes announce durationMs 0:
142
+ // there is no phase to match, so no override may be written.
143
+ rig.announceSwap({ delta: 0, durationMs: 0, phase: "instant" });
144
+ await nextTick();
145
+ expect(rig.morphToken()).toBe("");
146
+ await vi.advanceTimersByTimeAsync(500);
147
+ expect(rig.morphToken()).toBe("");
148
+ });
149
+
150
+ it("ignores announcements while the surface is still unfolding", async () => {
151
+ vi.useFakeTimers();
152
+ freezeRaf();
153
+ stubDurations();
154
+ const rig = await mountRig();
155
+ rig.open.value = true;
156
+ // The machine's phase is opening* here: the morph is not armed yet, so
157
+ // an override would be stranded on the frame.
158
+ await vi.advanceTimersByTimeAsync(10);
159
+ if (rig.bodyEl()) {
160
+ rig.announceSwap({ delta: 60, durationMs: 150, phase: "exit" });
161
+ await nextTick();
162
+ }
163
+ expect(rig.morphToken()).toBe("");
164
+ await vi.advanceTimersByTimeAsync(600);
165
+ expect(rig.morphToken()).toBe("");
166
+ });
167
+
168
+ it("drops the override when the modal unmounts mid-sweep", async () => {
169
+ vi.useFakeTimers();
170
+ freezeRaf();
171
+ stubDurations();
172
+ const rig = await mountRig();
173
+ rig.open.value = true;
174
+ await vi.advanceTimersByTimeAsync(600);
175
+
176
+ rig.announceSwap({ delta: 80, durationMs: 150, phase: "exit" });
177
+ await nextTick();
178
+ const frame = rig.frameEl();
179
+ expect(frame?.style.getPropertyValue("--hk-modal-morph-duration")).toBe("150ms");
180
+
181
+ // Unmount with the sweep still in flight: the timer must not outlive
182
+ // the component, and the token must not survive on the detached node.
183
+ for (const app of mounts.splice(0)) app.unmount();
184
+ await nextTick();
185
+ expect(frame?.style.getPropertyValue("--hk-modal-morph-duration")).toBe("");
186
+ });
187
+ });
188
+
189
+ describe("HkModal publishes its own fold for the step flow", () => {
190
+ /** Box model: the frame hugs its content up to `cap`; the content probe
191
+ * reports `content` plus the chrome the calibration measures. */
192
+ let content = 0;
193
+ let cap = Number.POSITIVE_INFINITY;
194
+ const realOffsetHeight = Object.getOwnPropertyDescriptor(
195
+ HTMLElement.prototype,
196
+ "offsetHeight",
197
+ );
198
+
199
+ function stubBox(initialContent: number, capPx: number): void {
200
+ content = initialContent;
201
+ cap = capPx;
202
+ Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
203
+ configurable: true,
204
+ get(this: HTMLElement) {
205
+ if (this.classList.contains("hk-modal-content")) {
206
+ return Math.min(content + 96, cap);
207
+ }
208
+ if (this.classList.contains("hk-modal-body-inner")) return content;
209
+ return 0;
210
+ },
211
+ });
212
+ }
213
+
214
+ function restoreBox(): void {
215
+ if (realOffsetHeight) {
216
+ Object.defineProperty(
217
+ HTMLElement.prototype,
218
+ "offsetHeight",
219
+ realOffsetHeight,
220
+ );
221
+ }
222
+ }
223
+
224
+ function stubClip(): void {
225
+ const real = window.getComputedStyle.bind(window);
226
+ vi.stubGlobal("getComputedStyle", (el: Element, ...rest: unknown[]) => {
227
+ const style = real(el as Element, ...(rest as []));
228
+ if (!(el instanceof HTMLElement)) return style;
229
+ if (el.classList.contains("hk-modal-content")) {
230
+ return {
231
+ ...style,
232
+ transitionDuration: "0.3s",
233
+ getPropertyValue: (name: string) =>
234
+ name === "--hk-sheet-morph" ? "clip" : style.getPropertyValue(name),
235
+ } as CSSStyleDeclaration;
236
+ }
237
+ return style;
238
+ });
239
+ }
240
+
241
+ afterEach(() => {
242
+ restoreBox();
243
+ });
244
+
245
+ it("republishes the morph's real fold span and its landing", async () => {
246
+ vi.useFakeTimers();
247
+ freezeRaf();
248
+ stubClip();
249
+ // Content starts at 500 (frame 596) and the sheet is uncapped, so the
250
+ // morph can fold: shrinking the content must publish a conceal.
251
+ stubBox(500, Number.POSITIVE_INFINITY);
252
+ const rig = await mountRig();
253
+ rig.open.value = true;
254
+ await vi.advanceTimersByTimeAsync(600);
255
+ expect(rig.bodyEl()).not.toBeNull();
256
+
257
+ const stages: unknown[] = [];
258
+ let settled = 0;
259
+ rig.bodyEl()!.addEventListener(SHEET_SWEEP_STAGE_EVENT, (e) => {
260
+ stages.push((e as CustomEvent).detail);
261
+ });
262
+ rig.bodyEl()!.addEventListener(SHEET_SWEEP_SETTLE_EVENT, () => {
263
+ settled += 1;
264
+ });
265
+
266
+ // The sweep needs real frames: pump rAF from here on (a frozen frame
267
+ // loop would strand the morph's warmup, as its own docs warn).
268
+ const frames: FrameRequestCallback[] = [];
269
+ vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
270
+ frames.push(cb);
271
+ return frames.length;
272
+ });
273
+ vi.stubGlobal("cancelAnimationFrame", () => {});
274
+
275
+ // The flow owns the new height and announces; the modal measures,
276
+ // stages its conceal, and publishes the span it will sweep.
277
+ content = 300;
278
+ rig.announceSwap({ delta: -200, durationMs: 150, phase: "enter" });
279
+ await nextTick();
280
+ expect(stages).toEqual([
281
+ { direction: "conceal", from: 596, to: 396, sweep: expect.any(Number) },
282
+ ]);
283
+
284
+ // …and the landing is published when the sweep finishes, carrying the
285
+ // sweep's own identity (without it a consumer cannot tell its landing
286
+ // from another dance's teardown).
287
+ const landed: Array<{ sweep?: number }> = [];
288
+ rig.bodyEl()!.addEventListener(SHEET_SWEEP_SETTLE_EVENT, (e) => {
289
+ landed.push((e as CustomEvent<{ sweep?: number }>).detail ?? {});
290
+ });
291
+ for (let i = 0; i < 4; i += 1) {
292
+ for (const cb of frames.splice(0)) cb(i * 16);
293
+ await vi.advanceTimersByTimeAsync(60);
294
+ }
295
+ await vi.advanceTimersByTimeAsync(1200);
296
+ expect(settled).toBeGreaterThan(0);
297
+ expect(landed.length).toBeGreaterThan(0);
298
+ expect(typeof landed[0]?.sweep).toBe("number");
299
+ });
300
+
301
+ it("publishes NOTHING when the sheet is capped and cannot fold", async () => {
302
+ vi.useFakeTimers();
303
+ freezeRaf();
304
+ stubClip();
305
+ // Content that already overflows the cap: the natural height cannot
306
+ // change, so the morph stages no sweep at all — the flow must park
307
+ // nothing (its span defaults to 0).
308
+ stubBox(900, 596);
309
+ const rig = await mountRig();
310
+ rig.open.value = true;
311
+ await vi.advanceTimersByTimeAsync(600);
312
+
313
+ const stages: unknown[] = [];
314
+ let settled = 0;
315
+ rig.bodyEl()!.addEventListener(SHEET_SWEEP_STAGE_EVENT, (e) => {
316
+ stages.push((e as CustomEvent).detail);
317
+ });
318
+ rig.bodyEl()!.addEventListener(SHEET_SWEEP_SETTLE_EVENT, () => {
319
+ settled += 1;
320
+ });
321
+
322
+ content = 850;
323
+ rig.announceSwap({ delta: -50, durationMs: 150, phase: "enter" });
324
+ await nextTick();
325
+ await vi.advanceTimersByTimeAsync(1200);
326
+ expect(stages).toEqual([]);
327
+ expect(settled).toBe(0);
328
+ });
329
+ });
@@ -13,7 +13,11 @@ import { useSurfaceMachine } from "../composables/useSurfaceMachine";
13
13
  import { useSurfaceContentHold } from "../composables/useSurfaceContentHold";
14
14
  import { useSizeMorph } from "../composables/useSizeMorph";
15
15
 
16
- import { STEPFLOW_SWAP_EVENT } from "./HkStepFlow";
16
+ import {
17
+ SHEET_SWEEP_SETTLE_EVENT,
18
+ SHEET_SWEEP_STAGE_EVENT,
19
+ STEPFLOW_SWAP_EVENT,
20
+ } from "./HkStepFlow";
17
21
  import HButton from "./HkButton";
18
22
  import HFab from "./HkFab";
19
23
  import HSpinner from "./HkSpinner";
@@ -205,24 +209,70 @@ export default defineComponent({
205
209
  deferRemeasure: () =>
206
210
  machine.phase.value === "openingFrom" ||
207
211
  machine.phase.value === "openingTo",
212
+ // Republish the morph's real sweep span and its landing on the body
213
+ // element (bubbling), so content choreography can park against the
214
+ // landing geometry and release exactly when it happens instead of
215
+ // guessing the cap and the warmup (2026-09-22 verification finding).
216
+ onSweepStage: (info) => {
217
+ bodyRef.value?.dispatchEvent(
218
+ new CustomEvent(SHEET_SWEEP_STAGE_EVENT, { bubbles: true, detail: info }),
219
+ );
220
+ },
221
+ onSweepSettle: (info) => {
222
+ bodyRef.value?.dispatchEvent(
223
+ new CustomEvent(SHEET_SWEEP_SETTLE_EVENT, {
224
+ bubbles: true,
225
+ detail: info,
226
+ }),
227
+ );
228
+ },
208
229
  });
209
230
  let previouslyFocused: HTMLElement | null = null;
210
231
  let unmounted = false;
211
232
 
212
233
  // 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();
234
+ // flow root at the edge where the sheet must morph: the exit edge for
235
+ // a GROW (the new height is already in the flow, so the sheet finishes
236
+ // growing while the old body leaves) and the enter edge for a SHRINK
237
+ // (the flow held its old height until the old body finished playing).
238
+ // The morph's settle debounce is tuned for streaming bursts; a step
239
+ // swap is one clean change whose sweep must land INSIDE its 0.15s
240
+ // phase (2026-09-22 user directive, round 10) — so the event
241
+ // short-circuits straight into remeasure() and overrides the sheet's
242
+ // own sweep duration for this one morph (the stylesheet default stays
243
+ // the 0.3s family standard for every other morph). The override is
244
+ // cleared on the same duration+grace watchdog the sweep itself uses.
245
+ let morphDurationTimer: ReturnType<typeof setTimeout> | null = null;
246
+ const clearMorphDuration = (): void => {
247
+ if (morphDurationTimer !== null) {
248
+ clearTimeout(morphDurationTimer);
249
+ morphDurationTimer = null;
250
+ }
251
+ contentRef.value?.style.removeProperty("--hk-modal-morph-duration");
252
+ };
253
+ const onStepflowSwap = (event: Event): void => {
254
+ if (machine.phase.value !== "open") {
255
+ // A swap announced while the surface is closing/opening: drop any
256
+ // override a previous morph left behind instead of letting it ride
257
+ // into the next open (it is otherwise only cleared by its own
258
+ // ms+350 timer).
259
+ clearMorphDuration();
260
+ return;
261
+ }
262
+ const frame = contentRef.value;
263
+ const ms = (event as CustomEvent<{ durationMs?: number }>).detail?.durationMs;
264
+ if (frame && typeof ms === "number" && ms > 0) {
265
+ frame.style.setProperty("--hk-modal-morph-duration", `${ms}ms`);
266
+ morph.remeasure();
267
+ if (morphDurationTimer !== null) clearTimeout(morphDurationTimer);
268
+ morphDurationTimer = setTimeout(clearMorphDuration, ms + 350);
269
+ } else {
270
+ morph.remeasure();
271
+ }
220
272
  };
221
- onMounted(() => {
222
- bodyRef.value?.addEventListener(STEPFLOW_SWAP_EVENT, onStepflowSwap);
223
- });
224
273
  onBeforeUnmount(() => {
225
274
  bodyRef.value?.removeEventListener(STEPFLOW_SWAP_EVENT, onStepflowSwap);
275
+ clearMorphDuration();
226
276
  });
227
277
 
228
278
  const overlayZ = computed(() => handle.value?.zIndex ?? 0);
@@ -430,6 +480,26 @@ export default defineComponent({
430
480
  },
431
481
  });
432
482
 
483
+ // The swap listener must follow the ELEMENT, not the component
484
+ // lifecycle: the render returns null until `machine.mounted` flips, so
485
+ // an onMounted-time attach silently missed for every
486
+ // mount-closed-then-open consumer — i.e. every chest wizard
487
+ // (2026-09-22 R1 verification finding: this wiring was dead at
488
+ // runtime, which is why the sheet kept morphing on the observer's
489
+ // 150ms debounce and the 0.3s stylesheet default). Post-flush on the
490
+ // mount flag instead: the listener lands on the real node and comes
491
+ // off with it.
492
+ watch(
493
+ machine.mounted,
494
+ (isMounted) => {
495
+ const el = bodyRef.value;
496
+ if (!el) return;
497
+ if (isMounted) el.addEventListener(STEPFLOW_SWAP_EVENT, onStepflowSwap);
498
+ else el.removeEventListener(STEPFLOW_SWAP_EVENT, onStepflowSwap);
499
+ },
500
+ { flush: "post", immediate: true },
501
+ );
502
+
433
503
  // --- Windowed mode ---
434
504
 
435
505
  function setupWindowed(container: HTMLElement) {
@@ -1,19 +1,29 @@
1
- // Direction-aware sliding step bodies for HkStepFlow.
1
+ // Two-phase step bodies for HkStepFlow.
2
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. 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).
3
+ // A swap runs TWO phases, each half of `--hk-stepflow-duration` (0.15s of
4
+ // the 0.3s family standard):
5
+ //
6
+ // EXIT — the OLD body alone is visible: `hk-stepflow-leave-to` slides
7
+ // it out (forward exits left, back exits right) and fades it,
8
+ // with the sharp ease-in;
9
+ // ENTER — the old body is gone and the NEW one fades in PLACE (no
10
+ // travel) with the expressive ease-out.
11
+ //
12
+ // The new body is mounted from frame one with `hk-stepflow-enter-pending`
13
+ // (invisible, but laid out so the flow owns the new height immediately),
14
+ // which is what makes the two bodies structurally unable to overlap —
15
+ // the doubled-ghost flash of the simultaneous cross-slide (2026-09-22
16
+ // user directive, round 10).
17
+ //
18
+ // This stylesheet owns ALL of the motion: direction, travel distance and
19
+ // easings are driven by the `data-direction` attribute and those two
20
+ // classes; the component only flips them and schedules the phases. The
21
+ // sheet's own morph is phase-matched by the hosting modal, which reads
22
+ // the swap event's `durationMs`.
13
23
  //
14
24
  // Duration and travel resolve from local custom properties so consumers
15
25
  // 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.
26
+ // sheet's morph window so the sweep and the slide land together.
17
27
 
18
28
  .hk-step-flow {
19
29
  --hk-stepflow-duration: 0.3s;
@@ -52,62 +62,85 @@
52
62
  padding-bottom: var(--hk-stepflow-header-gap, var(--space-16, 1rem));
53
63
  }
54
64
 
55
- // The swap stage: entering bodies own the flow's height; the leaving one
56
- // overlays it, out of flow and pointer-transparent for the slide.
65
+ // The swap stage: the entering body owns the flow's height; the leaving
66
+ // one overlays it, out of flow and pointer-transparent for the exit.
57
67
  .hk-stepflow-bodies {
58
68
  position: relative;
59
69
  }
60
70
 
61
71
  .hk-stepflow-body {
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.
72
+ // ENTER grammar (what a body computes once its `hk-stepflow-enter-pending`
73
+ // class lifts at the phase-2 edge): a plain fade in place — the new
74
+ // content never travels (2026-09-22 user directive).
64
75
  transition:
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);
76
+ opacity calc(var(--hk-stepflow-duration, 0.3s) / 2) cubic-bezier(0.16, 1, 0.3, 1);
67
77
  }
68
78
 
69
79
  .hk-stepflow-body.leaving {
70
80
  position: absolute;
81
+ // Default anchor: the stage's TOP. The component measures how far the
82
+ // stage travelled during the swap patch and writes that as an inline
83
+ // `top`, which is the only correct answer across host shapes — a
84
+ // bottom-docked sheet whose box grew, a capped box that could not grow,
85
+ // and an in-flow stage all differ, and a CSS anchor can only encode one
86
+ // of them (a guessed bottom anchor cut 276px off a capped sheet's
87
+ // outgoing step).
71
88
  top: 0;
72
89
  left: 0;
73
90
  right: 0;
74
91
  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.
92
+ // LEAVE grammar: the sharp ease-in counterpart, applied for the whole
93
+ // exit phase (the class lands on the same patch that flips the body
94
+ // into `leaving`, so the slide starts from the already-painted state).
77
95
  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);
96
+ opacity calc(var(--hk-stepflow-duration, 0.3s) / 2) cubic-bezier(0.5, 0, 0.75, 0),
97
+ transform calc(var(--hk-stepflow-duration, 0.3s) / 2) cubic-bezier(0.5, 0, 0.75, 0);
80
98
  }
81
99
 
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 {
100
+ // The parked new body of a shrink whose sheet will fold. Out of flow (the
101
+ // stage keeps the OLD height through the fold) and offset by the span the
102
+ // sheet published, so it already sits on the line the fold lands on; the
103
+ // component releases both when the sheet reports that landing.
104
+ .hk-stepflow-body.hk-stepflow-enter-tail {
105
+ position: absolute;
106
+ top: 0;
107
+ left: 0;
108
+ right: 0;
109
+ }
110
+
111
+ // The staged new body: LAID OUT (so the flow owns the new height from
112
+ // frame one — the sheet can grow through the exit phase) but invisible,
113
+ // which is what guarantees the old and new bodies are never on screen
114
+ // together. `visibility` (not display/removal) keeps it measurable and
115
+ // keeps a re-swap from ever destroying its reveal.
116
+ .hk-stepflow-body.hk-stepflow-enter-pending {
88
117
  opacity: 0;
89
- transform: translateX(var(--hk-stepflow-travel, 24px));
118
+ visibility: hidden;
90
119
  }
91
120
 
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 {
121
+ // Direction-aware travel, on the LEAVING body only: forward exits LEFT,
122
+ // back exits RIGHT (the classic vocabulary). The entering body appears in
123
+ // place, so it carries no direction rule at all.
124
+ .hk-stepflow-bodies[data-direction="forward"] .hk-stepflow-body.hk-stepflow-leave-to {
94
125
  opacity: 0;
95
126
  transform: translateX(calc(-1 * var(--hk-stepflow-travel, 24px)));
96
127
  }
97
128
 
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)));
129
+ .hk-stepflow-bodies[data-direction="back"] .hk-stepflow-body.hk-stepflow-leave-to {
130
+ opacity: 0;
131
+ transform: translateX(var(--hk-stepflow-travel, 24px));
104
132
  }
105
133
 
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 {
134
+ /* RTL: reading direction flips, so the exit mirrors — forward exits right
135
+ * (house pattern: HkListTransition.scss [dir="rtl"] form). */
136
+ [dir="rtl"] .hk-stepflow-bodies[data-direction="forward"] .hk-stepflow-body.hk-stepflow-leave-to {
108
137
  transform: translateX(var(--hk-stepflow-travel, 24px));
109
138
  }
110
139
 
140
+ [dir="rtl"] .hk-stepflow-bodies[data-direction="back"] .hk-stepflow-body.hk-stepflow-leave-to {
141
+ transform: translateX(calc(-1 * var(--hk-stepflow-travel, 24px)));
142
+ }
143
+
111
144
  /* Reduced motion: bodies swap instantly. The component's duration probe
112
145
  * reads the zeroed duration and settles without staging, so this block
113
146
  * is the single switch for the whole choreography (the sheet morph