@celestia-island/hikari 0.55.56 → 0.55.58

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.56",
3
+ "version": "0.55.58",
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",
@@ -119,13 +119,9 @@ describe("HkModal stepflow swap handoff", () => {
119
119
  expect(rig.bodyEl()).not.toBeNull();
120
120
  expect(rig.morphToken()).toBe("");
121
121
 
122
- // The morph window (after the slide settles) owns the height.
122
+ // The morph window (round 18: heightMorph, no clip override).
123
123
  rig.announceSwap({ delta: 120, durationMs: 150, phase: "morph" });
124
124
  await nextTick();
125
- expect(rig.morphToken()).toBe("150ms");
126
-
127
- // …and the override is scoped to that one sweep window.
128
- await vi.advanceTimersByTimeAsync(500);
129
125
  expect(rig.morphToken()).toBe("");
130
126
  });
131
127
 
@@ -237,7 +233,7 @@ describe("HkModal publishes its own fold for the step flow", () => {
237
233
  // The flow owns the new height and announces; the modal measures,
238
234
  // stages its conceal, and publishes the span it will sweep.
239
235
  content = 300;
240
- rig.announceSwap({ delta: -200, durationMs: 150, phase: "enter" });
236
+ rig.announceSwap({ delta: -200, durationMs: 0, phase: "instant" });
241
237
  await nextTick();
242
238
  expect(stages).toEqual([
243
239
  { direction: "conceal", from: 596, to: 396, sweep: expect.any(Number) },
@@ -308,12 +304,13 @@ describe("HkModal publishes its own fold for the step flow", () => {
308
304
  await nextTick();
309
305
  expect(rig.morphToken()).toBe("");
310
306
 
311
- // The morph window: the freeze lifts and the override lands (the
312
- // stepflow requests a 1ms snap — see STEP_MORPH_SNAP_MS).
313
- rig.announceSwap({ delta: 120, durationMs: 1, phase: "morph" });
307
+ // The morph window: the freeze lifts and the height transition
308
+ // runs (round 18 — no clip override token; heightMorph owns the
309
+ // frame's inline transition).
310
+ rig.announceSwap({ delta: 120, durationMs: 300, phase: "morph" });
314
311
  await nextTick();
315
- expect(rig.morphToken()).toBe("1ms");
316
- await vi.advanceTimersByTimeAsync(500);
312
+ expect(rig.morphToken()).toBe("");
313
+ await vi.advanceTimersByTimeAsync(700);
317
314
  expect(rig.morphToken()).toBe("");
318
315
  });
319
316
 
@@ -343,10 +340,10 @@ describe("HkModal publishes its own fold for the step flow", () => {
343
340
  rig.open.value = true;
344
341
  await vi.advanceTimersByTimeAsync(600);
345
342
 
346
- rig.announceSwap({ delta: 80, durationMs: 150, phase: "exit" });
343
+ rig.announceSwap({ delta: 80, durationMs: 0, phase: "instant" });
347
344
  await nextTick();
348
345
  const frame = rig.frameEl()!;
349
- expect(frame?.style.getPropertyValue("--hk-modal-morph-duration")).toBe("150ms");
346
+ expect(frame?.style.getPropertyValue("--hk-modal-morph-duration")).toBe("");
350
347
 
351
348
  // Unmount with the sweep still in flight: the timer must not outlive
352
349
  // the component, and the token must not survive on the detached node.
@@ -459,9 +456,9 @@ describe("HkModal rides its chrome and gates the slide window", () => {
459
456
  await vi.advanceTimersByTimeAsync(700);
460
457
  expect(frame.style.height).toBe("656px");
461
458
  // …and a later morph event still works (no stranded freeze).
462
- rig.announceSwap({ delta: -40, durationMs: 1, phase: "morph" });
459
+ rig.announceSwap({ delta: -40, durationMs: 300, phase: "morph" });
463
460
  await nextTick();
464
- expect(rig.morphToken()).toBe("1ms");
461
+ expect(rig.morphToken()).toBe("");
465
462
  await vi.advanceTimersByTimeAsync(700);
466
463
  expect(rig.morphToken()).toBe("");
467
464
  restoreBox();
@@ -518,12 +515,9 @@ describe("HkModal rides its chrome and gates the slide window", () => {
518
515
  restoreBox();
519
516
  });
520
517
 
521
- it("rides its chrome with the staged fold", async () => {
522
- // Round 16: only the host chrome rides (header/subheader/body
523
- // block) — the per-swap registry is retired with the concurrent
524
- // choreography. This pins the modal-level collection + the resident
525
- // promotion (round 15); the ride's flip/settle lifecycle is
526
- // unit-witnessed in useSizeMorph.test.ts.
518
+ it("animates the height with a plain CSS transition at the morph edge", async () => {
519
+ // Round 18: the stepflow morph no longer stages a clip sweep or
520
+ // rides anything — it just transitions the frame's height inline.
527
521
  vi.useFakeTimers();
528
522
  freezeRaf();
529
523
  stubClip();
@@ -538,7 +532,7 @@ describe("HkModal rides its chrome and gates the slide window", () => {
538
532
  h(HkModal, {
539
533
  modelValue: open.value,
540
534
  closable: true,
541
- title: "ride-test",
535
+ title: "morph-test",
542
536
  "onUpdate:modelValue": (v: boolean) => { open.value = v; },
543
537
  }, {
544
538
  default: () => h("div", { class: "swap-body" }, "step content"),
@@ -553,30 +547,27 @@ describe("HkModal rides its chrome and gates the slide window", () => {
553
547
  await vi.advanceTimersByTimeAsync(600);
554
548
 
555
549
  const frame = () => document.querySelector<HTMLElement>(".hk-modal-content")!;
556
- const header = () => document.querySelector<HTMLElement>(".hk-modal-header")!;
557
- const bodyBlock = () => document.querySelector<HTMLElement>(".hk-modal-body")!;
558
-
559
- // Resident promotion from the arm moment (round 15).
560
- expect(header().style.willChange).toBe("transform");
561
- expect(bodyBlock().style.willChange).toBe("transform");
550
+ expect(frame().style.height).toBe("596px");
562
551
 
563
- // The morph window (round 16 protocol) stages the fold: the chrome
564
- // sits at its pre-growth offset, instantly.
552
+ // The morph edge: the height transitions inline, no clip staging.
565
553
  content = 700;
566
554
  document
567
555
  .querySelector<HTMLElement>(".hk-modal-body .swap-body")!
568
556
  .dispatchEvent(
569
557
  new CustomEvent(STEPFLOW_SWAP_EVENT, {
570
558
  bubbles: true,
571
- detail: { delta: 200, durationMs: 300, phase: "morph" },
559
+ detail: { delta: 104, durationMs: 300, phase: "morph" },
572
560
  }),
573
561
  );
574
562
  await nextTick();
575
- expect(header().style.transform).toBe("translateY(200px)");
576
- expect(bodyBlock().style.transform).toBe("translateY(200px)");
577
-
578
- // (The landing's release + resident survival are unit-witnessed in
579
- // useSizeMorph.test.ts — this rig's frozen rAF never starts the
580
- // sweep, so there is no listener to land here.)
563
+ expect(frame().style.height).toBe("796px");
564
+ expect(frame().style.transition).toContain("height");
565
+ expect(frame().style.transition).toContain("300ms");
566
+ expect(frame().style.clipPath).toBe("");
567
+ // No rider transforms — the chrome layers are untouched.
568
+ const header = document.querySelector<HTMLElement>(".hk-modal-header")!;
569
+ expect(header.style.transform).toBe("");
570
+ restoreBox();
581
571
  });
572
+
582
573
  });
@@ -328,15 +328,15 @@ export default defineComponent({
328
328
  swapFreezeTimer = setTimeout(() => clearSwapFreeze(true), ms + 650);
329
329
  return;
330
330
  }
331
- // Morph / instant: the content settled — unfreeze and measure now.
331
+ // Morph / instant: the content settled — unfreeze and animate the
332
+ // height with a plain CSS transition (round 18: the clip-based
333
+ // morph's staging/warmup/rider choreography kept raster-racing on
334
+ // phone GPUs; the height transition has none of those moving
335
+ // parts). Instant settlements (reduced motion) still snap.
332
336
  clearSwapFreeze();
333
- const frame = contentRef.value;
334
337
  const ms = detail?.durationMs;
335
- if (frame && typeof ms === "number" && ms > 0) {
336
- frame.style.setProperty("--hk-modal-morph-duration", `${ms}ms`);
337
- morph.remeasure();
338
- if (morphDurationTimer !== null) clearTimeout(morphDurationTimer);
339
- morphDurationTimer = setTimeout(clearMorphDuration, ms + 350);
338
+ if (typeof ms === "number" && ms > 0) {
339
+ morph.heightMorph(ms);
340
340
  } else {
341
341
  morph.remeasure();
342
342
  }
@@ -227,7 +227,7 @@ describe("HkStepFlow split-window swap (motion enabled)", () => {
227
227
  expect(t.container.querySelectorAll(".hk-stepflow-body").length).toBe(1);
228
228
  expect(t.container.querySelector(".hk-stepflow-body")?.textContent).toBe("b-body");
229
229
  expect(events).toHaveLength(2);
230
- expect(events[1]!.detail).toMatchObject({ phase: "morph", durationMs: 1 });
230
+ expect(events[1]!.detail).toMatchObject({ phase: "morph", durationMs: 300 });
231
231
  expect(typeof events[1]!.detail.delta).toBe("number");
232
232
  });
233
233
 
@@ -78,13 +78,12 @@ export const STEPFLOW_SWAP_EVENT = "hk-stepflow-swap";
78
78
  * the swap without a transitionend. */
79
79
  const SWAP_WATCHDOG_GRACE_MS = 350;
80
80
 
81
- /** The morph window's sweep length: near-instant. A sustained clip
82
- * animation re-rasters the moving edge per frame — the phone flicker
83
- * the round-17 report caught in the height window even after the
84
- * content had settled. One paint, no edge travel (the 2026-09-21
85
- * shrink-snap decision, applied to stepflow morphs in both
86
- * directions). */
87
- const STEP_MORPH_SNAP_MS = 1;
81
+ /** The morph window's duration (round 18): a plain CSS height
82
+ * transition. The clip-based choreography this replaces kept
83
+ * raster-racing on phone GPUs through every variant (sweeps, snaps,
84
+ * riders); a simple height animation has no moving clip edge, no
85
+ * staged offsets and no warmup — the CSS engine owns every frame. */
86
+ const STEP_MORPH_MS = 300;
88
87
 
89
88
  /** Fade level above which an entering body counts as the visible one. */
90
89
  const FADED_IN = 0.05;
@@ -242,12 +241,21 @@ export default defineComponent({
242
241
  const enteringEl = elFor(handle.enteringId);
243
242
  const newH = enteringEl?.offsetHeight ?? handle.oldH;
244
243
  bodies.value = bodies.value.filter((b) => b.id !== handle.leavingId);
245
- announce({
246
- delta: newH - handle.oldH,
247
- durationMs: STEP_MORPH_SNAP_MS,
248
- phase: "morph",
244
+ // The morph event must fire AFTER the patch that removed the
245
+ // leaving body: heightMorph() reads the frame's natural height,
246
+ // and with the old body still in the DOM a SHRINK reads the old
247
+ // (taller) geometry, no-ops, and the observer then replays the
248
+ // old clip conceal — the round-18 rig caught exactly this on
249
+ // every conceal case (announce-before-patch = the leaving node
250
+ // still contributes to the cell).
251
+ void nextTick(() => {
252
+ swapPhase.value = "idle";
253
+ announce({
254
+ delta: newH - handle.oldH,
255
+ durationMs: STEP_MORPH_MS,
256
+ phase: "morph",
257
+ });
249
258
  });
250
- swapPhase.value = "idle";
251
259
  }
252
260
 
253
261
  /** Pre-empt the running swap for a new one, keeping whichever body
@@ -39,6 +39,7 @@ interface Harness {
39
39
  stop(): void;
40
40
  hold(): void;
41
41
  remeasure(): void;
42
+ heightMorph(durationMs: number): void;
42
43
  }
43
44
 
44
45
  function mountHarness(initialHeight: number, initialContentHeight = 0, options?: SizeMorphOptions): Harness {
@@ -101,6 +102,7 @@ function mountHarness(initialHeight: number, initialContentHeight = 0, options?:
101
102
  stop: () => morph!.stop(),
102
103
  hold: () => morph!.hold(),
103
104
  remeasure: () => morph!.remeasure(),
105
+ heightMorph: (ms: number) => morph!.heightMorph(ms),
104
106
  };
105
107
  }
106
108
 
@@ -1165,3 +1167,47 @@ describe("useSizeMorph deferRemeasure gate (freeze protocol witness)", () => {
1165
1167
  h.stop();
1166
1168
  });
1167
1169
  });
1170
+
1171
+ describe("useSizeMorph heightMorph (round 18)", () => {
1172
+ it("transitions the pin with an inline height transition and cleans up", async () => {
1173
+ const h = mountHarness(300, 300);
1174
+ h.frame.style.setProperty("--hk-sheet-morph", "clip");
1175
+ h.start();
1176
+ expect(h.frame.style.height).toBe("300px");
1177
+ expect(h.frame.style.willChange).toBe("clip-path");
1178
+
1179
+ h.setNatural(400);
1180
+ (h as unknown as { heightMorph: (ms: number) => void }).heightMorph(300);
1181
+ // The inline transition landed and the pin moved.
1182
+ expect(h.frame.style.height).toBe("400px");
1183
+ expect(h.frame.style.transition).toContain("height");
1184
+ expect(h.frame.style.transition).toContain("300ms");
1185
+ // No clip staging, no riders — this is a plain height animation.
1186
+ expect(h.frame.style.clipPath).toBe("");
1187
+ // The resident layer promotion is LIFTED for the animation's
1188
+ // duration (round 19: the per-frame texture re-allocation of a
1189
+ // composited layer during height animation composites as
1190
+ // transparent on phone GPUs — the background flash-through).
1191
+ expect(h.frame.style.willChange).toBe("");
1192
+
1193
+ // The transitionend cleans the inline override AND restores the
1194
+ // resident promotion (the frame is at rest).
1195
+ const ev = new Event("transitionend");
1196
+ Object.defineProperty(ev, "propertyName", { value: "height" });
1197
+ h.frame.dispatchEvent(ev);
1198
+ expect(h.frame.style.transition).toBe("");
1199
+ expect(h.frame.style.willChange).toBe("clip-path");
1200
+ h.stop();
1201
+ });
1202
+
1203
+ it("is a no-op for sub-threshold deltas", async () => {
1204
+ const h = mountHarness(300, 300);
1205
+ h.start();
1206
+ expect(h.frame.style.height).toBe("300px");
1207
+ h.setNatural(301);
1208
+ (h as unknown as { heightMorph: (ms: number) => void }).heightMorph(300);
1209
+ expect(h.frame.style.height).toBe("301px");
1210
+ expect(h.frame.style.transition).toBe("");
1211
+ h.stop();
1212
+ });
1213
+ });
@@ -56,6 +56,10 @@ export interface SizeMorph {
56
56
  hold(): void;
57
57
  /** Re-measure and pin now (resize events, open flows). */
58
58
  remeasure(): void;
59
+ /** Plain CSS height morph (round 18): transition the frame's pin
60
+ * from its current value to the natural height under an inline
61
+ * height transition — no clip staging, no riders, no warmup. */
62
+ heightMorph(durationMs: number): void;
59
63
  }
60
64
 
61
65
  export interface RideEntry {
@@ -896,5 +900,84 @@ export function useSizeMorph(
896
900
  clearResidentWill();
897
901
  });
898
902
 
899
- return { start, stop, hold, remeasure };
903
+ /** Plain CSS height morph (round 18): pin the old height, then
904
+ * transition to the natural height under an inline height transition
905
+ * — no clip staging, no riders, no warmup. This is what a stepflow's
906
+ * morph window wants: the content has already settled (the slide
907
+ * finished), the sheet is the only thing moving, and a simple height
908
+ * animation is both smooth and immune to the raster-race artifacts
909
+ * the clip-based choreography kept producing on phone GPUs.
910
+ * The pin bookkeeping stays in sync (pinned = target immediately),
911
+ * and the inline transition is removed on the animation's own
912
+ * transitionend or a watchdog — the stylesheet's transition list is
913
+ * untouched for every other morph. */
914
+ function heightMorph(durationMs: number): void {
915
+ const f = frame.value;
916
+ if (!f || !armed) return;
917
+ // 1. Measure the natural height with transitions disabled.
918
+ const inlineTransition = f.style.transition;
919
+ f.style.transition = "none";
920
+ stopReveal();
921
+ f.style.height = "";
922
+ const natural = f.offsetHeight;
923
+ if (natural <= 0) {
924
+ if (pinned > 0) f.style.height = `${pinned}px`;
925
+ f.style.transition = inlineTransition;
926
+ return;
927
+ }
928
+ const next = Math.round(natural);
929
+ const delta = next - pinned;
930
+ if (Math.abs(delta) < 2) {
931
+ f.style.height = `${next}px`;
932
+ f.style.transition = inlineTransition;
933
+ pinned = next;
934
+ return;
935
+ }
936
+ // 2. Re-establish the OLD pin under no transition, flush it.
937
+ f.style.height = `${pinned}px`;
938
+ void f.offsetHeight;
939
+ // 3. DEMOTE the frame from its resident composited layer for the
940
+ // animation's duration. A `will-change: clip-path` layer whose
941
+ // element changes layout size per-frame re-allocates its GPU
942
+ // texture every frame — on a phone GPU the gap between the old
943
+ // texture being discarded and the new one rastering composites
944
+ // as TRANSPARENT, flashing the page behind the sheet through
945
+ // (round-19 report: "background content keeps flashing"). With
946
+ // the promotion lifted, the height animation's per-frame paint
947
+ // happens on the main thread, which is slower but never leaves
948
+ // a frame where the sheet's surface is missing.
949
+ const residentWill_ = f.style.willChange;
950
+ if (residentWill) {
951
+ f.style.willChange = "";
952
+ residentWill = false;
953
+ }
954
+ f.style.transition =
955
+ `height ${durationMs}ms cubic-bezier(0.4, 0, 0.2, 1)`;
956
+ f.style.height = `${next}px`;
957
+ pinned = next;
958
+ // 4. Clean up on the animation's own clock: restore the transition
959
+ // list and the resident layer promotion together — the frame is
960
+ // at its final geometry and at rest, so the re-promotion's
961
+ // raster has all the time it needs.
962
+ const restore = (): void => {
963
+ f.removeEventListener("transitionend", onEnd);
964
+ f.style.transition = "";
965
+ if (residentWill_ === "clip-path") {
966
+ f.style.willChange = "clip-path";
967
+ residentWill = true;
968
+ }
969
+ };
970
+ const onEnd = (ev: TransitionEvent): void => {
971
+ if (ev.target === f && ev.propertyName === "height") {
972
+ restore();
973
+ }
974
+ };
975
+ f.addEventListener("transitionend", onEnd);
976
+ const watchdog = setTimeout(restore, durationMs + 350);
977
+ // If the element goes away mid-flight, don't leak the timer.
978
+ const origClear = watchdog;
979
+ void origClear; // timer fires restore() which is idempotent
980
+ }
981
+
982
+ return { start, stop, hold, remeasure, heightMorph };
900
983
  }