@celestia-island/hikari 0.55.312 → 0.55.313

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.312",
3
+ "version": "0.55.313",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
@@ -15,7 +15,7 @@ import HkModal from "./HkModal";
15
15
  *
16
16
  * happy-dom has no layout engine, so the frame's offsetHeight is stubbed
17
17
  * at the HTMLElement prototype level BEFORE mount — the morph's
18
- * openingFrom-tick arm then measures 500px and pins it, and every
18
+ * open-edge arm then measures 500px and pins it, and every
19
19
  * mid-leave assertion reads the inline height string directly.
20
20
  */
21
21
  const mounts: ReturnType<typeof createApp>[] = [];
@@ -128,7 +128,7 @@ describe("HkModal leave-window height pin", () => {
128
128
  try {
129
129
  const rig = await mountRig();
130
130
 
131
- // Open to rest: the enter-edge arm measured 500px and pinned it.
131
+ // Open to rest: the open-edge arm measured 500px and pinned it.
132
132
  rig.open.value = true;
133
133
  await vi.advanceTimersByTimeAsync(600);
134
134
  expect(rig.frameEl()).not.toBeNull();
@@ -157,7 +157,7 @@ describe("HkModal leave-window height pin", () => {
157
157
  }
158
158
  });
159
159
 
160
- it("flushes growth deferred through the enter with an open-edge remeasure", async () => {
160
+ it("leaves the frame unpinned through the enter; the open edge pins the settled height", async () => {
161
161
  vi.useFakeTimers();
162
162
  freezeRaf();
163
163
  stubPhoneViewport();
@@ -166,24 +166,65 @@ describe("HkModal leave-window height pin", () => {
166
166
  try {
167
167
  const rig = await mountRig();
168
168
 
169
- // Open: the enter-edge arm pins the enter-time height (400px).
169
+ // Open: nothing arms during the enter — the frame stays height:auto
170
+ // so content that streams in mid-enter reflows it live instead of
171
+ // sitting clipped behind a mount-time pin (2026-11-07 chest report:
172
+ // topic windows fetched their detail mid-enter and the bottom of
173
+ // the window stayed missing for the whole open animation).
170
174
  rig.open.value = true;
171
175
  await vi.advanceTimersByTimeAsync(150); // mid-enter, past the flip
172
- expect(rig.pinHeight()).toBe("400px");
176
+ expect(rig.frameEl()).not.toBeNull();
177
+ expect(rig.pinHeight()).toBe("");
173
178
 
174
- // Content grows mid-enter — with the enter's defer gate the pin
175
- // must NOT chase it (the choreography's height-relative geometry
176
- // stays put; in a real browser the RO fires and is deferred too).
179
+ // Content grows mid-enter: still unpinned — the growth IS the
180
+ // frame (auto height follows it frame-by-frame).
177
181
  layoutH = 700;
178
182
  await vi.advanceTimersByTimeAsync(50);
179
- expect(rig.pinHeight()).toBe("400px");
183
+ expect(rig.pinHeight()).toBe("");
180
184
 
181
- // The open edge flushes the deferred growth one tick after the
182
- // transition classes clear — the pin lands at the grown height.
185
+ // The open edge pins the settled natural height directly — no
186
+ // deferred flush, no second growth spurt after the enter.
183
187
  await vi.advanceTimersByTimeAsync(450);
184
188
  expect(rig.pinHeight()).toBe("700px");
185
189
 
186
- // Sanity: the surface really rested open before the flush assertion.
190
+ // Sanity: the surface really rested open before the pin assertion.
191
+ expect(rig.frameEl()).not.toBeNull();
192
+ } finally {
193
+ restoreLayout();
194
+ }
195
+ });
196
+
197
+ it("drops the held pin at a reopen-interrupt edge; the open edge re-pins", async () => {
198
+ vi.useFakeTimers();
199
+ freezeRaf();
200
+ stubPhoneViewport();
201
+ stubDurations();
202
+ stubLayout(500);
203
+ try {
204
+ const rig = await mountRig();
205
+
206
+ // Open to rest (the open-edge arm pins 500px), then request a
207
+ // close: the leave holds the pin (the fold owns the frame).
208
+ rig.open.value = true;
209
+ await vi.advanceTimersByTimeAsync(600);
210
+ expect(rig.pinHeight()).toBe("500px");
211
+ rig.open.value = false;
212
+ await vi.advanceTimersByTimeAsync(150); // mid-leave
213
+ expect(rig.frameEl()).not.toBeNull();
214
+ expect(rig.pinHeight()).toBe("500px");
215
+
216
+ // Reopen mid-leave: the reopening edge must DROP the held pin —
217
+ // otherwise the reopen enter plays at the OLD content's height and
218
+ // clips whatever the new content renders (the sibling of the
219
+ // first-open bug this suite retired).
220
+ rig.open.value = true;
221
+ await vi.advanceTimersByTimeAsync(150); // mid-reopen-enter
222
+ expect(rig.frameEl()).not.toBeNull();
223
+ expect(rig.pinHeight()).toBe("");
224
+
225
+ // The open edge re-pins at rest (content unchanged → same height).
226
+ await vi.advanceTimersByTimeAsync(450);
227
+ expect(rig.pinHeight()).toBe("500px");
187
228
  expect(rig.frameEl()).not.toBeNull();
188
229
  } finally {
189
230
  restoreLayout();
@@ -456,14 +456,21 @@ describe("HkModal rides its chrome and gates the slide window", () => {
456
456
  stubClip();
457
457
  stubBox(500, Number.POSITIVE_INFINITY);
458
458
  stubPhoneViewport();
459
- const roInstances: Array<{ callback: () => void }> = [];
459
+ const roInstances: Array<{ callback: () => void; target: Element | null }> = [];
460
+ // Select the morph's observer by its OBSERVED TARGET, never by index:
461
+ // the overlay scrollbar attaches its own ResizeObserver at the mounted
462
+ // flip, and since the morph arms at the `open` edge (after the enter)
463
+ // the scrollbar's observer registers first.
464
+ const morphRo = () =>
465
+ roInstances.find((ro) => ro.target?.classList.contains("hk-modal-body-inner"))!;
460
466
  vi.stubGlobal("ResizeObserver", class {
461
467
  callback: () => void;
468
+ target: Element | null = null;
462
469
  constructor(cb: () => void) {
463
470
  this.callback = cb;
464
471
  roInstances.push(this);
465
472
  }
466
- observe() {}
473
+ observe(el: Element) { this.target = el; }
467
474
  disconnect() {}
468
475
  });
469
476
  const rig = await mountRig();
@@ -477,7 +484,7 @@ describe("HkModal rides its chrome and gates the slide window", () => {
477
484
  rig.announceSwap({ delta: 0, durationMs: 300, phase: "swap" });
478
485
  await nextTick();
479
486
  content = 560;
480
- roInstances[0]!.callback();
487
+ morphRo().callback();
481
488
  await vi.advanceTimersByTimeAsync(400);
482
489
  expect(frame.style.height).toBe("596px");
483
490
 
@@ -506,14 +513,19 @@ describe("HkModal rides its chrome and gates the slide window", () => {
506
513
  stubClip();
507
514
  stubBox(500, Number.POSITIVE_INFINITY);
508
515
  stubPhoneViewport();
509
- const roInstances: Array<{ callback: () => void }> = [];
516
+ const roInstances: Array<{ callback: () => void; target: Element | null }> = [];
517
+ // Same target-based selection as the safety-timer test: the scrollbar's
518
+ // observer registers before the morph's open-edge arm.
519
+ const morphRo = () =>
520
+ roInstances.find((ro) => ro.target?.classList.contains("hk-modal-body-inner"))!;
510
521
  vi.stubGlobal("ResizeObserver", class {
511
522
  callback: () => void;
523
+ target: Element | null = null;
512
524
  constructor(cb: () => void) {
513
525
  this.callback = cb;
514
526
  roInstances.push(this);
515
527
  }
516
- observe() {}
528
+ observe(el: Element) { this.target = el; }
517
529
  disconnect() {}
518
530
  });
519
531
  // No rAF stub: happy-dom maps rAF onto its timer queue, so the
@@ -528,7 +540,7 @@ describe("HkModal rides its chrome and gates the slide window", () => {
528
540
  rig.announceSwap({ delta: 0, durationMs: 300, phase: "swap" });
529
541
  await nextTick();
530
542
  content = 560;
531
- roInstances[0]!.callback();
543
+ morphRo().callback();
532
544
  await vi.advanceTimersByTimeAsync(700);
533
545
  expect(frame.style.height).toBe("596px");
534
546
 
@@ -540,7 +552,7 @@ describe("HkModal rides its chrome and gates the slide window", () => {
540
552
  // Harness validity + unfreeze proof: the SAME observer path now
541
553
  // moves the pin without any event.
542
554
  content = 700;
543
- roInstances[0]!.callback();
555
+ morphRo().callback();
544
556
  await vi.advanceTimersByTimeAsync(700);
545
557
  expect(frame.style.height).toBe("796px");
546
558
  restoreBox();
@@ -284,24 +284,27 @@ export default defineComponent({
284
284
  const innerRef = ref<HTMLElement>();
285
285
  // Content-driven size morphing: the frame follows content growth with
286
286
  // the height transition instead of snapping (see useSizeMorph). The
287
- // gate freezes resize-driven re-measurement through the enter unfold:
288
- // the choreography's geometry is height-relative (translateY 5% +
289
- // bottom-10% clip of the frame height), so content streaming in
290
- // mid-enter would otherwise recompute the pixel geometry under the
291
- // running animation (2026-09-16 chest field report — the frame
292
- // snapped 459→697px mid-unfold). The open phase edge flushes the
293
- // deferred growth with an animated remeasure.
294
- // The freeze flag (round 16): while a stepflow SLIDE is in flight the
295
- // sheet's height must not chase the flow's changing cell — the gate
296
- // below also returns true then, and the morph window's own event
297
- // flushes the frozen change with an explicit remeasure.
287
+ // morph arms at the machine's `open` edge only — never during the
288
+ // enter. Under the current whole-window zoom+rise choreography the
289
+ // open animation is opacity+transform on the frame: nothing about it
290
+ // reads the frame's height, so an enter-time pin has nothing left to
291
+ // protect, and pinning during the enter would freeze the frame at the
292
+ // mount-time height while `overflow: hidden` clips whatever content
293
+ // streams in behind it (2026-11-07 chest report — topic windows
294
+ // fetched their detail mid-enter and read as "the bottom of the
295
+ // window is missing" for the whole open animation, then grew). The
296
+ // old arm-and-freeze existed for the retired height-relative unveil
297
+ // (translateY fraction + bottom clip of the frame height), whose
298
+ // geometry a mid-enter resize would have corrupted; that grammar is
299
+ // gone, so the freeze is gone with it.
300
+ // The freeze flag (round 16) stays: while a stepflow SLIDE is in
301
+ // flight the sheet's height must not chase the flow's changing cell —
302
+ // the gate below also returns true then, and the morph window's own
303
+ // event flushes the frozen change with an explicit remeasure.
298
304
  let swapFrozen = false;
299
305
  let swapFreezeTimer: ReturnType<typeof setTimeout> | null = null;
300
306
  const morph = useSizeMorph(contentRef, innerRef, {
301
- deferRemeasure: () =>
302
- swapFrozen ||
303
- machine.phase.value === "openingFrom" ||
304
- machine.phase.value === "openingTo",
307
+ deferRemeasure: () => swapFrozen,
305
308
  // The sheet's chrome and content block ride the fold edge in
306
309
  // lockstep with the clip sweep: the title bar glides with the edge
307
310
  // instead of being sliced by it mid-sweep, the content block starts
@@ -593,26 +596,32 @@ export default defineComponent({
593
596
  backGuard.push();
594
597
  }
595
598
  surfTrack.run();
596
- // Arm the size morph WITH the enter (one tick later, once the
597
- // mounting patch landed): the pin locks the frame's height for
598
- // the whole unfold so late-streaming content cannot move the
599
- // choreography mid-flight (see the morph's gate above). The
600
- // zero-duration fast paths reach `open` before the tick — the
601
- // open branch's own start() covers them.
602
- void nextTick(() => {
603
- const p = machine.phase.value;
604
- if (p !== "openingFrom" && p !== "openingTo") return;
605
- if (p === "openingFrom" || p === "openingTo") morph.start();
606
- });
599
+ // No morph arm here, on purpose: the enter is opacity+transform
600
+ // (whole-window zoom+rise), which never reads the frame's
601
+ // height — the frame stays at `height: auto` so content that
602
+ // streams in mid-enter reflows it live instead of being clipped
603
+ // behind a stale mount-time pin (see the morph gate above). The
604
+ // size morph arms once, at the `open` edge below.
605
+ // A REOPEN-INTERRUPT (OPEN mid-close) lands here too: the close
606
+ // request held the previous cycle's pin (morph.hold(), so the
607
+ // fold played on a stable box) and nothing else would drop it —
608
+ // the reopen enter would then play pinned at the OLD content's
609
+ // height, clipping new content exactly like the first-open bug
610
+ // this file retired. Release the pin so every enter — first or
611
+ // interrupted — starts at `height: auto`; the hold-kept pin's
612
+ // other purpose (the close completing) is served by the
613
+ // finalize edge's own release.
614
+ morph.release();
607
615
  } else if (to === "open") {
608
616
  surfTrack.cancel();
609
617
  onAfterEnter();
610
- // Idempotent re-arm for the fast paths (see openingFrom).
618
+ // The ONLY morph arm (see the openingFrom note): the enter has
619
+ // landed, so calibrating and pinning here measures the frame at
620
+ // rest — with the enter classes no longer in play one tick
621
+ // later, the follow-up remeasure re-checks the natural height
622
+ // under the base height transition (a growth that landed in the
623
+ // last tick of the enter animates instead of snapping).
611
624
  morph.start();
612
- // Flush the growth deferred through the unfold. One tick later
613
- // the transition classes are off the frame, so the base height
614
- // transition animates the pin change instead of the enter's
615
- // opacity/transform-only transition list swallowing it as a snap.
616
625
  void nextTick(() => {
617
626
  if (machine.phase.value === "open") morph.remeasure();
618
627
  });
@@ -34,10 +34,13 @@ const REVEAL_WARMUP_FRAMES = 2;
34
34
  export interface SizeMorph {
35
35
  /** Arm the morph: observe the content and pin the frame's natural
36
36
  * height on every change. Call once the surface finished its open
37
- * enter transition — pinning during enter would override the
38
- * choreography's own height animation. (Surfaces whose enter is
39
- * height-INDEPENDENT — HkModal's clip+transform unfold — may arm
40
- * earlier with a `deferRemeasure` gate, see below.) */
37
+ * enter — the enter must own the frame's geometry (HkModal and
38
+ * HkSelectPanel both arm at the machine's `open` edge; the enter
39
+ * classes are off the frame by then, so calibration reads the rest
40
+ * state). There is no early-arm protocol anymore: the modal's enter
41
+ * is opacity+transform and never reads the frame's height, so an
42
+ * enter-time pin would only freeze a stale height under
43
+ * `overflow: hidden`. */
41
44
  start(): void;
42
45
  /** Disarm the morph and release the frame to `height: auto` — call
43
46
  * before a surface's leave/close so the exit animation owns the
@@ -48,12 +51,20 @@ export interface SizeMorph {
48
51
  * choreography owns the frame's geometry through the whole leave and
49
52
  * the pin keeps it stable (a mid-leave content change must not resize
50
53
  * the folding frame). The next start() re-arms and, because the pin
51
- * was kept, a re-pin to the SAME natural height is a no-op (the
52
- * typical reopen-interrupt case — held content is unchanged, delta
54
+ * was kept, a re-pin to the SAME natural height is a no-op (a reopen
55
+ * that goes hold → start without releasing — no current HkModal path
56
+ * does this, its reopen edge releases the pin first; held content is unchanged, delta
53
57
  * zero; a re-pin to a CHANGED natural while an enter's transition
54
58
  * classes own the frame lands without a height animation, i.e. snaps
55
59
  * — acceptable, the enter's own choreography owns that moment). */
56
60
  hold(): void;
61
+ /** Drop the height pin and hand the frame back to `height: auto`
62
+ * without disarming anything (a hold()ed morph stays disarmed; an
63
+ * armed one keeps observing). In-flight reveals are torn down first,
64
+ * exactly like stop(). This is the reopen-interrupt edge's tool: a
65
+ * reopen enter must not inherit the previous cycle's pin, or new
66
+ * content sits clipped behind the old height for the whole enter. */
67
+ release(): void;
57
68
  /** Re-measure and pin now (resize events, open flows). */
58
69
  remeasure(): void;
59
70
  /** Plain CSS height morph (round 18): transition the frame's pin
@@ -69,13 +80,14 @@ export interface RideEntry {
69
80
 
70
81
  export interface SizeMorphOptions {
71
82
  /** While this returns true, resize-driven re-measurements are deferred
72
- * (the current pin stays). HkModal freezes the frame's height during
73
- * the enter unfold: the choreography's translateY(5%) + bottom-10%
74
- * clip are fractions of the frame height, so late-streaming content
75
- * resizing the frame mid-enter would recompute the geometry under the
76
- * running animation (2026-09-16 chest field report — the frame snapped
77
- * 459→697px mid-unfold). The caller flushes the deferred growth with
78
- * an explicit remeasure() at the open edge. */
83
+ * (the current pin stays). HkModal freezes the frame's height while a
84
+ * stepflow SLIDE is in flight: the sheet must not chase the flow's
85
+ * changing cell mid-slide, and the slide's morph event flushes the
86
+ * frozen change with an explicit remeasure once the content settles.
87
+ * (The gate used to also cover the modal's own enter — that open
88
+ * choreography is opacity+transform today and never reads the frame's
89
+ * height, so the enter arms nothing and defers nothing; see the morph
90
+ * note in HkModal.) */
79
91
  deferRemeasure?: () => boolean;
80
92
  /** Fold riders (2026-09-23 round-14 chest report): elements collected
81
93
  * when a clip sweep is STAGED whose translateY must track the sweep's
@@ -623,13 +635,12 @@ export function useSizeMorph(
623
635
  * finished) frame — the only moment guaranteed free of transition-class
624
636
  * flex rules. Never calibrate from a remeasure sample: the first
625
637
  * remeasure can itself be the contaminated one (a frozen enter leaves
626
- * the flex rules behind when the surface is mid-repair). HkModal's
627
- * reopen-interrupt arm violates the precondition on purpose (it
628
- * calibrates a pinned, enter-classed frame): benign today because no
629
- * current enter/leave class carries height/flex rules (the inline pin
630
- * beats the mobile sheet's static height:auto), and the contamination
631
- * guard below degrades any future SCSS regression to a dropped pin
632
- * rather than a corrupted one. */
638
+ * the flex rules behind when the surface is mid-repair). Every current
639
+ * consumer arms at the machine's `open` edge, on a resting frame. If a
640
+ * future caller ever arms earlier (mid-enter), this precondition is
641
+ * violated by construction and the contamination guard below degrades
642
+ * the bad sample to a dropped pin rather than a corrupted one — it
643
+ * does not make early arming correct. */
633
644
  function calibrate(): void {
634
645
  const f = frame.value;
635
646
  const c = content.value;
@@ -834,9 +845,9 @@ export function useSizeMorph(
834
845
  }
835
846
 
836
847
  function onResize(): void {
837
- // Frozen window (e.g. HkModal's enter unfold): keep the current pin;
838
- // the caller flushes the accumulated change with an explicit
839
- // remeasure() once the choreography hands the height back.
848
+ // Frozen window (HkModal's stepflow SLIDE freeze — the only defer
849
+ // gate left): keep the current pin; the morph event flushes the
850
+ // accumulated change with an explicit remeasure.
840
851
  if (options.deferRemeasure?.()) return;
841
852
  // Debounce the choreography: content can change in a burst (a list
842
853
  // transition shrinking rows over several frames, a textarea growing
@@ -1076,5 +1087,5 @@ export function useSizeMorph(
1076
1087
  setTimeout(restore, durationMs + 350);
1077
1088
  }
1078
1089
 
1079
- return { start, stop, hold, remeasure, heightMorph };
1090
+ return { start, stop, hold, release, remeasure, heightMorph };
1080
1091
  }