@celestia-island/hikari 0.55.49 → 0.55.51

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.49",
3
+ "version": "0.55.51",
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,54 @@
1
+ import { dirname, join, resolve } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ import * as sass from "sass";
5
+ import { describe, expect, it } from "vitest";
6
+
7
+ /**
8
+ * Slot-glyph guard contract (2026-09-23).
9
+ *
10
+ * A raw svg passed as HkButton slot content (the lucide `<Icon size={14}/>`
11
+ * child pattern chest uses in every admin table action cell) is a flex
12
+ * item of the button. Under a squeezed host row, the host reset's
13
+ * `svg { max-width: 100% }` clamps the glyph to the button's SHRUNKEN
14
+ * content box: measured 3.3x14 px slivers in ProvidersView, 10x14 in
15
+ * OAuthProvidersView/DeviceModelsView, 0x14 in WebhooksView/GroupTab —
16
+ * the "行内操作按钮莫名其妙很小" report. `.hk-btn > svg { flex: none }`
17
+ * pins the glyph to its intrinsic size; the button's automatic minimum
18
+ * size then covers the full glyph, so an auto-layout table column or
19
+ * toolbar row grows to fit instead of shrinking the controls into
20
+ * slivers. Verified in a real (headless) browser before/after: 3.3px →
21
+ * 14px glyph width at the same viewport.
22
+ *
23
+ * The guard is asserted against the COMPILED sheet, not the source, so
24
+ * a reformat or a selector rename cannot silently unguard it.
25
+ */
26
+
27
+ const componentsDir = resolve(dirname(fileURLToPath(import.meta.url)));
28
+
29
+ function compileSheet(): string {
30
+ return sass.compile(join(componentsDir, "HkButton.scss"), {
31
+ loadPaths: [
32
+ resolve(componentsDir, ".."),
33
+ resolve(componentsDir, "../styles"),
34
+ resolve(componentsDir, "../styles/theme"),
35
+ ],
36
+ silenceDeprecations: ["import", "global-builtin", "legacy-js-api"],
37
+ }).css;
38
+ }
39
+
40
+ describe("HkButton slot-glyph guard", () => {
41
+ it("pins direct svg slot glyphs against flex squeeze", () => {
42
+ const css = compileSheet();
43
+ const rule = css.match(/\.hk-btn\s*>\s*svg\s*\{[^}]*\}/);
44
+ expect(rule, "compiled HkButton.scss must carry the .hk-btn > svg guard").toBeTruthy();
45
+ expect(rule![0]).toMatch(/flex:\s*none/);
46
+ });
47
+
48
+ it("keeps the icon prop's own fixed boxes flex:none (same contract)", () => {
49
+ const css = compileSheet();
50
+ const block = css.match(/\.hk-btn-icon\s*,\s*\.hk-btn-suffix\s*\{[^}]*\}/);
51
+ expect(block).toBeTruthy();
52
+ expect(block![0]).toMatch(/flex:\s*none/);
53
+ });
54
+ });
@@ -88,6 +88,24 @@
88
88
  }
89
89
  }
90
90
 
91
+ /* ---- Raw slot glyphs ----
92
+ The same fixed-glyph contract covers a raw svg passed as slot content
93
+ (the lucide `<Icon size={14}/>` child pattern): as a flex item of the
94
+ button it inherits flex-shrink:1, and under a squeezed host row the
95
+ host reset's `svg { max-width: 100% }` clamps it to the button's
96
+ SHRUNKEN content box — measured 3.3x14 in a real browser (chest admin
97
+ table action cells, 2026-09-23 user report: 行内操作按钮莫名其妙变成
98
+ 一条小竖线). flex:none pins the glyph to its intrinsic size; the
99
+ button's automatic minimum size then covers the full glyph, so an
100
+ auto-layout table column or toolbar row grows to fit instead of
101
+ shrinking the controls into slivers. Only the DIRECT child needs the
102
+ guard: a glyph wrapped in a span (the .hk-btn-icon box, an inline
103
+ label, …) sits behind a non-replaced flex item whose min-content
104
+ already carries the svg's intrinsic size. */
105
+ .hk-btn > svg {
106
+ flex: none;
107
+ }
108
+
91
109
  /* ---- Icon-only square ---- */
92
110
  /* An icon-only HkButton (glyph, no label) occupies a SQUARE footprint
93
111
  by default: padding collapses fully and width locks to the size
@@ -3,6 +3,7 @@ import { createApp, defineComponent, h, nextTick, ref } from "vue";
3
3
 
4
4
  import HkModal from "./HkModal";
5
5
  import { STEPFLOW_SWAP_EVENT } from "./HkStepFlow";
6
+ import { useSheetRide } from "../runtime/sheetRide";
6
7
 
7
8
  const SHEET_SWEEP_STAGE_EVENT = "hk-sheet-sweep-stage";
8
9
  const SHEET_SWEEP_SETTLE_EVENT = "hk-sheet-sweep-settle";
@@ -325,4 +326,109 @@ describe("HkModal publishes its own fold for the step flow", () => {
325
326
  expect(stages).toEqual([]);
326
327
  expect(settled).toBe(0);
327
328
  });
329
+
330
+ it("rides its chrome and registered descendants with the staged fold", async () => {
331
+ // Round 14: the fold's clip edge must never slice the title. When a
332
+ // sweep stages, the modal's chrome and content probe ride the edge,
333
+ // and a descendant that registered through the sheet-ride registry
334
+ // (the way HkStepFlow registers its entering body) rides as a
335
+ // reveal COUNTER — staged at the mirrored negative offset.
336
+ vi.useFakeTimers();
337
+ freezeRaf();
338
+ stubClip();
339
+ stubBox(500, Number.POSITIVE_INFINITY);
340
+
341
+ let registered = false;
342
+ const Rider = defineComponent({
343
+ setup() {
344
+ const registry = useSheetRide();
345
+ return () =>
346
+ h("div", {
347
+ class: "rider-el",
348
+ ref: (el: unknown) => {
349
+ const node = el as HTMLElement | null;
350
+ if (node && registry && !registered) {
351
+ registered = true;
352
+ registry.register({ el: node, counter: true });
353
+ }
354
+ },
355
+ }, "rider");
356
+ },
357
+ });
358
+
359
+ const container = document.createElement("div");
360
+ document.body.appendChild(container);
361
+ containers.push(container);
362
+ const open = ref(false);
363
+ const Wrapper = defineComponent({
364
+ setup() {
365
+ return () =>
366
+ h(HkModal, {
367
+ modelValue: open.value,
368
+ closable: true,
369
+ title: "ride-test",
370
+ "onUpdate:modelValue": (v: boolean) => { open.value = v; },
371
+ }, {
372
+ default: () => h("div", { class: "swap-body" }, [h(Rider)]),
373
+ });
374
+ },
375
+ });
376
+ const app = createApp(Wrapper);
377
+ mounts.push(app);
378
+ app.mount(container);
379
+ await nextTick();
380
+ open.value = true;
381
+ await vi.advanceTimersByTimeAsync(600);
382
+
383
+ const frame = () => document.querySelector<HTMLElement>(".hk-modal-content")!;
384
+ const header = () => document.querySelector<HTMLElement>(".hk-modal-header")!;
385
+ const bodyBlock = () => document.querySelector<HTMLElement>(".hk-modal-body")!;
386
+ const riderEl = () => document.querySelector<HTMLElement>(".rider-el")!;
387
+ expect(registered).toBe(true);
388
+
389
+ // The sweep needs real frames from here on.
390
+ const frames: FrameRequestCallback[] = [];
391
+ vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
392
+ frames.push(cb);
393
+ return frames.length;
394
+ });
395
+ vi.stubGlobal("cancelAnimationFrame", () => {});
396
+
397
+ // A GROW announced at the flow's exit edge: content 500 -> 700, so
398
+ // the frame folds +200. The chrome rides at the pre-growth offset,
399
+ // the registered counter at the mirrored negative.
400
+ content = 700;
401
+ document
402
+ .querySelector<HTMLElement>(".hk-modal-body .swap-body")!
403
+ .dispatchEvent(
404
+ new CustomEvent(STEPFLOW_SWAP_EVENT, {
405
+ bubbles: true,
406
+ detail: { delta: 200, durationMs: 150, phase: "exit" },
407
+ }),
408
+ );
409
+ await nextTick();
410
+ expect(header().style.transform).toBe("translateY(200px)");
411
+ expect(bodyBlock().style.transform).toBe("translateY(200px)");
412
+ expect(riderEl().style.transform).toBe("translateY(-200px)");
413
+ expect(header().style.willChange).toBe("transform");
414
+
415
+ // Two warmup frames, then the sweep: everything flips to its target
416
+ // under the mirrored transform transition.
417
+ for (let i = 0; i < 2; i += 1) {
418
+ for (const cb of frames.splice(0)) cb(i * 16);
419
+ await vi.advanceTimersByTimeAsync(16);
420
+ }
421
+ expect(header().style.transform).toBe("translateY(0px)");
422
+ expect(riderEl().style.transform).toBe("translateY(0px)");
423
+ expect(header().style.transition).toMatch(/^transform 0\.3s /);
424
+
425
+ // The landing releases the rides with the frame.
426
+ const ev = new Event("transitionend");
427
+ Object.defineProperty(ev, "propertyName", { value: "clip-path" });
428
+ frame().dispatchEvent(ev);
429
+ expect(header().style.transform).toBe("");
430
+ expect(bodyBlock().style.transform).toBe("");
431
+ expect(riderEl().style.transform).toBe("");
432
+ expect(header().style.willChange).toBe("");
433
+ });
328
434
  });
@@ -11,7 +11,8 @@ import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composa
11
11
  import { useSurfaceTransition } from "../composables/useSurfaceTransition";
12
12
  import { useSurfaceMachine } from "../composables/useSurfaceMachine";
13
13
  import { useSurfaceContentHold } from "../composables/useSurfaceContentHold";
14
- import { useSizeMorph } from "../composables/useSizeMorph";
14
+ import { useSizeMorph, type RideEntry } from "../composables/useSizeMorph";
15
+ import { provideSheetRide } from "../runtime/sheetRide";
15
16
 
16
17
  import { STEPFLOW_SWAP_EVENT } from "./HkStepFlow";
17
18
 
@@ -210,10 +211,43 @@ export default defineComponent({
210
211
  // running animation (2026-09-16 chest field report — the frame
211
212
  // snapped 459→697px mid-unfold). The open phase edge flushes the
212
213
  // deferred growth with an animated remeasure.
214
+ // Fold-ride registry (round 14): descendants register elements the
215
+ // fold must not slice or drag (the stepflow's entering body rides as
216
+ // a counter); merged into collectRide below.
217
+ const sheetRide = provideSheetRide();
213
218
  const morph = useSizeMorph(contentRef, innerRef, {
214
219
  deferRemeasure: () =>
215
220
  machine.phase.value === "openingFrom" ||
216
221
  machine.phase.value === "openingTo",
222
+ // The sheet's chrome and content block ride the fold edge in
223
+ // lockstep with the clip sweep: the title bar glides with the edge
224
+ // instead of being sliced by it mid-sweep, the content block starts
225
+ // at its pre-morph position (a grow no longer teleports the stack
226
+ // upward behind the staged clip), and a shrink's landing re-pin is
227
+ // visually a no-op instead of a delta snap (2026-09-23 round-14
228
+ // chest report: "the title collapses — clipped mid-text, never
229
+ // dragged with the fold").
230
+ collectRide: (): RideEntry[] => {
231
+ const out: RideEntry[] = [];
232
+ const frame = contentRef.value;
233
+ if (frame) {
234
+ const header = frame.querySelector<HTMLElement>(".hk-modal-header");
235
+ if (header) out.push({ el: header });
236
+ const subheader = frame.querySelector<HTMLElement>(".hk-modal-subheader");
237
+ if (subheader) out.push({ el: subheader });
238
+ }
239
+ // The BODY BLOCK rides, not the body inner: translating the
240
+ // inner made the scroll container itself scrollable for the
241
+ // sweep's duration (transforms contribute to scrollable
242
+ // overflow), and the transient scrollbar/gutter poisoned the
243
+ // post-settle measurement into a phantom +20px re-sweep (R1 rig
244
+ // finding). Riding the block keeps the scrollport's content
245
+ // untransformed; the block's own poke below the frame lands
246
+ // off-screen on a bottom-docked sheet.
247
+ if (bodyRef.value) out.push({ el: bodyRef.value });
248
+ out.push(...sheetRide.snapshot());
249
+ return out;
250
+ },
217
251
  // Republish the morph's real sweep span and its landing on the body
218
252
  // element (bubbling), so content choreography can park against the
219
253
  // landing geometry and release exactly when it happens instead of
@@ -47,23 +47,35 @@
47
47
  // directive asks for, with no pin).
48
48
  .hk-stepflow-body {
49
49
  grid-area: 1 / 1;
50
- // ENTER: fade in place with a standard ease — no travel, no other
51
- // property. The element is laid out and PAINTED from the moment it
52
- // mounts (opacity: 0 is compositor-only), so the first frame of the
53
- // fade never triggers a re-raster. The NEGATIVE delay starts the fade
54
- // already partway up its curve, so the new content crosses the eye's
55
- // perceptual threshold (~0.15 opacity) the moment the old one drops
56
- // below it — without it, the strictly sequential grammar leaves a
57
- // ~70-125ms blank body window between the two phases, which was the
58
- // "flicker" the user's device kept reporting (round 12, second video).
50
+ // ENTER: fade in place from TRUE zero with a standard ease — no travel,
51
+ // no delay tricks. The element is laid out and PAINTED from the moment
52
+ // it mounts (opacity: 0 is compositor-only), and the COMPONENT fires
53
+ // the enter edge at 65% of the exit phase and flips the opacity-hiding
54
+ // class off imperatively, so the fade ramps up under the old body's
55
+ // still-visible tail (recycled at 90%, by then ~0.07) — no empty body
56
+ // ever paints between the phases (round 14, 2026-09-23: the earlier
57
+ // -40ms negative delay traded the blank window for a first-frame 0.53
58
+ // pop — "the model list always flickers once" — while the sequential
59
+ // edge still painted a ~33ms empty body; the overlap edge removes
60
+ // both).
59
61
  transition:
60
- opacity calc(var(--hk-stepflow-duration, 0.3s) / 2) cubic-bezier(0.4, 0, 0.2, 1) -40ms;
62
+ opacity calc(var(--hk-stepflow-duration, 0.3s) / 2) cubic-bezier(0.4, 0, 0.2, 1);
61
63
  }
62
64
 
63
- // The staged new body: transparent, untouchable, and at the CELL BOTTOM
64
- // until the enter edge. Deliberately NO `visibility: hidden` — that property
65
- // changes the render tree and the phone GPU re-rasters the layer when it
66
- // flips, which was the flash the user kept seeing (round 12 directive).
65
+ // The staged new body, staged in two independently-released layers:
66
+ //
67
+ // - `hk-stepflow-enter-pending` = END ANCHORING (align-self: end +
68
+ // pointer-events: none). It holds from mount until the RECYCLE patch —
69
+ // the one that collapses the cell — because a visible body without it
70
+ // stretches to the still-tall cell and jumps a full delta the frame
71
+ // its fade starts.
72
+ // - `hk-stepflow-enter-hidden` = OPACITY 0. It comes off at the ENTER
73
+ // EDGE (0.65 of the exit phase, imperatively) so the fade starts in
74
+ // the same frame the old body's fading tail still covers the stage.
75
+ //
76
+ // Deliberately NO `visibility: hidden` anywhere — that property changes
77
+ // the render tree and the phone GPU re-rasters the layer when it flips,
78
+ // which was the flash the user kept seeing (round 12 directive).
67
79
  // `align-self: end` does two things with zero positioning tricks: it stops
68
80
  // the grid from STRETCHING the shorter entering body to the old cell height
69
81
  // (so its measured height is the true content height, and the shrink's
@@ -72,11 +84,14 @@
72
84
  // appears at its final line instead of being wiped by the descending fold
73
85
  // edge and jumping at the landing.
74
86
  .hk-stepflow-body.hk-stepflow-enter-pending {
75
- opacity: 0;
76
87
  pointer-events: none;
77
88
  align-self: end;
78
89
  }
79
90
 
91
+ .hk-stepflow-body.hk-stepflow-enter-hidden {
92
+ opacity: 0;
93
+ }
94
+
80
95
  // The leaving body: fades out and slides in its direction. The slide is
81
96
  // the classic accelerating ease; the fade gets its own gentler curve so
82
97
  // the old content eases out over several frames.
@@ -76,18 +76,41 @@ describe("HkStepFlow simplified swap contract", () => {
76
76
  expect(decl).toContain("opacity");
77
77
  expect(decl).toContain(PHASE);
78
78
  expect(decl).toContain("cubic-bezier(0.4, 0, 0.2, 1)");
79
- // The negative delay starts the fade already partway up its curve,
80
- // closing the blank-body window between the two phases.
81
- expect(decl).toContain("-40ms");
79
+ // No delay tricks: the enter fade starts from TRUE zero (round 14 —
80
+ // the -40ms negative delay landed the first frame at ~0.53 opacity,
81
+ // which read as a pop). The blank window is closed by the component's
82
+ // overlap edge instead (see HkStepFlow.test.tsx).
83
+ expect(decl).not.toMatch(/-?\d*\.?\d+m?s\s*$/);
82
84
  expect(decl, "the enter phase never travels").not.toContain("transform");
83
85
  });
84
86
 
85
- it("stages the new body with opacity only — NO visibility flip", () => {
86
- const rule = blockFor(".hk-stepflow-body.hk-stepflow-enter-pending");
87
- expect(rule).toContain("opacity: 0");
88
- expect(rule).toContain("pointer-events: none");
89
- expect(rule).toContain("align-self: end");
90
- expect(rule, "visibility re-rasters the layer — the flash source").not.toContain("visibility");
87
+ it("registers the entering body as a counter rider of the sheet's fold", () => {
88
+ // The ride channel: the entering body holds its final position while
89
+ // the hosting sheet's content block glides with the fold edge (round
90
+ // 14; vertical travel of the new body was the round-7 rejection).
91
+ expect(tsx).toContain("useSheetRide");
92
+ expect(tsx).toContain("counter: true");
93
+ expect(tsx).toContain("unregisterRide");
94
+ // The reveal itself stays imperative and class-based — no style
95
+ // writes join the component (see the no-styles contract below).
96
+ expect(tsx).toContain('classList.remove("hk-stepflow-enter-hidden")');
97
+ });
98
+
99
+ it("fires the enter edge early so no blank body paints between phases", () => {
100
+ expect(tsx).toContain("ENTER_EDGE_SHARE = 0.65");
101
+ expect(tsx).toContain("earlyTimer");
102
+ });
103
+
104
+ it("stages the new body in two independently-released layers — NO visibility flip", () => {
105
+ const anchor = blockFor(".hk-stepflow-body.hk-stepflow-enter-pending");
106
+ expect(anchor).toContain("pointer-events: none");
107
+ expect(anchor).toContain("align-self: end");
108
+ expect(anchor, "opacity must not ride the anchoring layer").not.toContain("opacity");
109
+ const hidden = blockFor(".hk-stepflow-body.hk-stepflow-enter-hidden");
110
+ expect(hidden).toContain("opacity: 0");
111
+ expect(hidden.trim(), "anchoring must not ride the opacity layer").toBe("opacity: 0;");
112
+ const declarations = scssRules.map((r) => r.body).join("\n");
113
+ expect(declarations, "visibility re-rasters the layer — the flash source").not.toContain("visibility");
91
114
  });
92
115
 
93
116
  it("keeps the leaving body's fade gentle while the slide accelerates", () => {
@@ -151,6 +174,9 @@ describe("HkStepFlow simplified swap contract", () => {
151
174
  expect(tsx).toContain("SWAP_WATCHDOG_GRACE_MS");
152
175
  expect(tsx).toContain('addEventListener("transitionend"');
153
176
  expect(tsx).toContain("event.target === el");
177
+ // A riding body's transform leg fires its own transitionend at the
178
+ // fold's landing — only the phase-owning opacity edge may advance.
179
+ expect(tsx).toContain('event.propertyName === "opacity"');
154
180
  });
155
181
 
156
182
  it("recycles the old DOM node at the phase boundary", () => {
@@ -1,8 +1,10 @@
1
1
  import { afterEach, describe, expect, it, vi } from "vitest";
2
- import { createApp, defineComponent, h, nextTick, ref } from "vue";
2
+ import { createApp, defineComponent, h, nextTick, provide, ref } from "vue";
3
3
 
4
4
  import HkStepFlow, { STEPFLOW_SWAP_EVENT } from "./HkStepFlow";
5
5
  import type { StepFlowSlotProps } from "./HkStepFlow";
6
+ import { provideSheetRide, SHEET_RIDE_KEY, type SheetRideRegistry } from "../runtime/sheetRide";
7
+ import type { RideEntry } from "../composables/useSizeMorph";
6
8
 
7
9
  const mounts: ReturnType<typeof createApp>[] = [];
8
10
  const containers: HTMLElement[] = [];
@@ -64,6 +66,15 @@ async function flushSwap(): Promise<void> {
64
66
  await nextTick();
65
67
  }
66
68
 
69
+ /** Drive the enter phase to its RECYCLE edge (real timers): the enter
70
+ * edge releases the fade, then the leaving body is recycled a share
71
+ * later (0.9 − 0.65 of a 150ms phase ≈ 38ms). */
72
+ async function settleEnter(): Promise<void> {
73
+ await flushSwap();
74
+ await new Promise((resolve) => setTimeout(resolve, 45));
75
+ await flushSwap();
76
+ }
77
+
67
78
  async function outlivePhase(): Promise<void> {
68
79
  await new Promise((resolve) => setTimeout(resolve, 430));
69
80
  }
@@ -120,11 +131,16 @@ function restoreHeights(): void {
120
131
 
121
132
  function endTransition(el: Element | null | undefined): void {
122
133
  if (!el) throw new Error("endTransition: element missing");
123
- el.dispatchEvent(new Event("transitionend", { bubbles: true }));
134
+ // Real engines always carry propertyName on transitionend; the phase
135
+ // closers guard on it (a riding body's transform leg fires its own).
136
+ const ev = new Event("transitionend", { bubbles: true });
137
+ Object.defineProperty(ev, "propertyName", { value: "opacity" });
138
+ el.dispatchEvent(ev);
124
139
  }
125
140
 
126
141
  afterEach(() => {
127
142
  vi.restoreAllMocks();
143
+ vi.useRealTimers();
128
144
  restoreHeights();
129
145
  for (const app of mounts.splice(0)) app.unmount();
130
146
  for (const el of containers.splice(0)) el.remove();
@@ -195,13 +211,15 @@ describe("HkStepFlow simplified swap (motion enabled)", () => {
195
211
  const pending = t.container.querySelector<HTMLElement>(".hk-stepflow-body.active");
196
212
  expect(leaving?.classList.contains("hk-stepflow-leave-to")).toBe(true);
197
213
  expect(pending?.classList.contains("hk-stepflow-enter-pending")).toBe(true);
214
+ expect(pending?.classList.contains("hk-stepflow-enter-hidden")).toBe(true);
198
215
 
199
216
  endTransition(leaving);
200
- await flushSwap();
217
+ await settleEnter();
201
218
  expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(1);
202
219
  const active = t.container.querySelector<HTMLElement>(".hk-stepflow-body.active");
203
220
  expect(active?.textContent).toBe("b-body");
204
221
  expect(active?.classList.contains("hk-stepflow-enter-pending")).toBe(false);
222
+ expect(active?.classList.contains("hk-stepflow-enter-hidden")).toBe(false);
205
223
 
206
224
  endTransition(active);
207
225
  await flushSwap();
@@ -225,7 +243,7 @@ describe("HkStepFlow simplified swap (motion enabled)", () => {
225
243
  expect((events[0]!.detail as { delta: number }).delta).toBe(120);
226
244
  // Drive the first swap to completion before starting the second.
227
245
  endTransition(t.container.querySelector(".hk-stepflow-body.leaving"));
228
- await flushSwap();
246
+ await settleEnter();
229
247
  endTransition(t.container.querySelector(".hk-stepflow-body.active"));
230
248
  await flushSwap();
231
249
  expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(1);
@@ -234,7 +252,7 @@ describe("HkStepFlow simplified swap (motion enabled)", () => {
234
252
  await flushSwap();
235
253
  expect(events.length).toBe(1);
236
254
  endTransition(t.container.querySelector(".hk-stepflow-body.leaving"));
237
- await flushSwap();
255
+ await settleEnter();
238
256
  expect(events.length).toBe(2);
239
257
  expect((events[1]!.detail as { phase: string }).phase).toBe("enter");
240
258
  expect((events[1]!.detail as { delta: number }).delta).toBe(-120);
@@ -266,7 +284,7 @@ describe("HkStepFlow simplified swap (motion enabled)", () => {
266
284
  expect(leaving?.textContent).toBe("a-body");
267
285
  expect(pending?.textContent).toBe("c-body");
268
286
  endTransition(leaving);
269
- await flushSwap();
287
+ await settleEnter();
270
288
  endTransition(t.container.querySelector(".hk-stepflow-body.active"));
271
289
  await flushSwap();
272
290
  expect(t.container.querySelector(".hk-stepflow-body")?.textContent).toBe("c-body");
@@ -283,6 +301,173 @@ describe("HkStepFlow simplified swap (motion enabled)", () => {
283
301
  expect(active?.textContent).toBe("c-body");
284
302
  expect(active?.classList.contains("hk-stepflow-enter-pending")).toBe(false);
285
303
  });
304
+
305
+ it("fires the enter edge at 65% and recycles at 90%, before transitionend", async () => {
306
+ // Round 14: the strictly sequential edge (transitionend) left a
307
+ // recycle→patch→fade pipeline gap that painted an empty body — the
308
+ // "the new list always flickers once" report. The overlap edge must
309
+ // advance the phase on its own timer and release the fade
310
+ // SYNCHRONOUSLY (imperative class flip) while the old body stays on
311
+ // stage; the recycle lands a share later in the same patch that
312
+ // drops the end anchoring.
313
+ vi.useFakeTimers();
314
+ stubMotion();
315
+ const t = mountStepFlow({ initial: "a" });
316
+ t.setCurrent("b");
317
+ await flushSwap();
318
+ const staged = t.container.querySelector<HTMLElement>(".hk-stepflow-body.active");
319
+ expect(staged?.classList.contains("hk-stepflow-enter-pending")).toBe(true);
320
+ expect(staged?.classList.contains("hk-stepflow-enter-hidden")).toBe(true);
321
+
322
+ // 0.65 × 150ms ≈ 98ms — the enter edge fires with no transitionend:
323
+ // the fade is released imperatively, the anchoring HOLDS (the cell is
324
+ // still the old, taller one), and the old body is still on stage.
325
+ vi.advanceTimersByTime(99);
326
+ const active = t.container.querySelector<HTMLElement>(".hk-stepflow-body.active");
327
+ expect(active?.classList.contains("hk-stepflow-enter-hidden")).toBe(false);
328
+ expect(active?.classList.contains("hk-stepflow-enter-pending")).toBe(true);
329
+ await flushSwap();
330
+ expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(2);
331
+
332
+ // 0.9 × 150ms = 135ms — the recycle: the old node goes, and the SAME
333
+ // patch drops the anchoring (no stretch-jump frame between them).
334
+ vi.advanceTimersByTime(37);
335
+ await flushSwap();
336
+ expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(1);
337
+ const settled = t.container.querySelector<HTMLElement>(".hk-stepflow-body.active");
338
+ expect(settled?.classList.contains("hk-stepflow-enter-pending")).toBe(false);
339
+ expect(settled?.classList.contains("hk-stepflow-enter-hidden")).toBe(false);
340
+
341
+ // A late transitionend from the (already recycled) leaving body is a
342
+ // guarded no-op.
343
+ endTransition(settled);
344
+ await flushSwap();
345
+ expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(1);
346
+ });
347
+
348
+ it("ignores transitionend events for non-opacity properties", async () => {
349
+ // A riding body's transform leg (the sheet fold) fires its own
350
+ // transitionend at the fold's landing — the swap must not settle
351
+ // while the enter fade is still running. The witness is the ride
352
+ // registration: settling releases it, so a stray transform end must
353
+ // leave it held and only the opacity edge may release it.
354
+ stubMotion();
355
+ const registered: RideEntry[] = [];
356
+ const unregistered: RideEntry[] = [];
357
+ const container = document.createElement("div");
358
+ document.body.appendChild(container);
359
+ containers.push(container);
360
+ const current = ref("a");
361
+ const Wrapper = defineComponent({
362
+ setup() {
363
+ provide(SHEET_RIDE_KEY, {
364
+ register: (entry: RideEntry) => {
365
+ registered.push(entry);
366
+ return () => unregistered.push(entry);
367
+ },
368
+ snapshot: () => registered,
369
+ });
370
+ return () =>
371
+ h(HkStepFlow, {
372
+ steps: STEPS,
373
+ modelValue: current.value,
374
+ "onUpdate:modelValue": (key: string) => { current.value = key; },
375
+ }, {
376
+ a: () => h("p", "a"), b: () => h("p", "b"),
377
+ c: () => h("p", "c"), d: () => h("p", "d"),
378
+ });
379
+ },
380
+ });
381
+ const app = createApp(Wrapper);
382
+ mounts.push(app);
383
+ app.mount(container);
384
+
385
+ current.value = "b";
386
+ await flushSwap();
387
+ endTransition(container.querySelector(".hk-stepflow-body.leaving"));
388
+ await settleEnter();
389
+ const active = container.querySelector<HTMLElement>(".hk-stepflow-body.active");
390
+ expect(active).not.toBeNull();
391
+ expect(unregistered).toHaveLength(0);
392
+
393
+ // The fold lands mid-fade: its transform transitionend must not
394
+ // settle the swap (the registration stays held).
395
+ const stray = new Event("transitionend", { bubbles: true });
396
+ Object.defineProperty(stray, "propertyName", { value: "transform" });
397
+ active!.dispatchEvent(stray);
398
+ await flushSwap();
399
+ expect(unregistered).toHaveLength(0);
400
+
401
+ // The opacity edge owns the settle.
402
+ endTransition(active);
403
+ await flushSwap();
404
+ expect(unregistered).toHaveLength(1);
405
+ expect(container.querySelector(".hk-stepflow-body")?.className).toBe(
406
+ "hk-stepflow-body active",
407
+ );
408
+ });
409
+
410
+ it("registers the entering body as a counter rider with the hosting sheet", async () => {
411
+ stubMotion();
412
+ // The REAL registry (not a mock): the swap must actually hold and
413
+ // release its registration — a mock's unregister callback could be a
414
+ // no-op and every assertion here would still pass (R2 finding 2).
415
+ let registry: SheetRideRegistry | null = null;
416
+ const container = document.createElement("div");
417
+ document.body.appendChild(container);
418
+ containers.push(container);
419
+ const current = ref("a");
420
+ const Wrapper = defineComponent({
421
+ setup() {
422
+ registry = provideSheetRide();
423
+ return () =>
424
+ h(HkStepFlow, {
425
+ steps: STEPS,
426
+ modelValue: current.value,
427
+ "onUpdate:modelValue": (key: string) => { current.value = key; },
428
+ }, {
429
+ a: () => h("p", "a"),
430
+ b: () => h("p", "b"),
431
+ c: () => h("p", "c"),
432
+ d: () => h("p", "d"),
433
+ });
434
+ },
435
+ });
436
+ const app = createApp(Wrapper);
437
+ mounts.push(app);
438
+ app.mount(container);
439
+
440
+ current.value = "b";
441
+ await flushSwap();
442
+ // Registered before the sheet could stage any sweep, as a reveal
443
+ // COUNTER (the entering body holds its final position while the
444
+ // sheet's block rides the fold).
445
+ const held = registry!.snapshot();
446
+ expect(held).toHaveLength(1);
447
+ expect(held[0]!.counter).toBe(true);
448
+ expect(held[0]!.el.classList.contains("hk-stepflow-body")).toBe(true);
449
+ // Settle the swap: the registration is released — the registry the
450
+ // sheet reads is actually empty again.
451
+ endTransition(container.querySelector(".hk-stepflow-body.leaving"));
452
+ await settleEnter();
453
+ endTransition(container.querySelector(".hk-stepflow-body.active"));
454
+ await flushSwap();
455
+ expect(registry!.snapshot()).toHaveLength(0);
456
+
457
+ // A preempted swap (exit phase interrupted by the next navigation,
458
+ // twice in a row) must not leak registrations either.
459
+ current.value = "c";
460
+ await flushSwap();
461
+ expect(registry!.snapshot()).toHaveLength(1);
462
+ current.value = "d";
463
+ await flushSwap();
464
+ endTransition(container.querySelector(".hk-stepflow-body.leaving"));
465
+ await settleEnter();
466
+ endTransition(container.querySelector(".hk-stepflow-body.active"));
467
+ await flushSwap();
468
+ expect(registry!.snapshot()).toHaveLength(0);
469
+ expect(container.querySelectorAll(".hk-stepflow-body").length).toBe(1);
470
+ });
286
471
  });
287
472
 
288
473
  describe("HkStepFlow sticky-header pin strategy", () => {