@celestia-island/hikari 0.55.51 → 0.55.52

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.51",
3
+ "version": "0.55.52",
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",
@@ -338,21 +338,14 @@ describe("HkModal publishes its own fold for the step flow", () => {
338
338
  stubClip();
339
339
  stubBox(500, Number.POSITIVE_INFINITY);
340
340
 
341
- let registered = false;
341
+ // The rider models the stepflow's ENTERING body: registered
342
+ // MID-CYCLE (never at arm time), so it stays on the per-swap
343
+ // promotion path — resident promotion is the chrome's alone.
344
+ let lateRegistry: ReturnType<typeof useSheetRide> = null;
342
345
  const Rider = defineComponent({
343
346
  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");
347
+ lateRegistry = useSheetRide();
348
+ return () => h("div", { class: "rider-el" }, "rider");
356
349
  },
357
350
  });
358
351
 
@@ -384,7 +377,9 @@ describe("HkModal publishes its own fold for the step flow", () => {
384
377
  const header = () => document.querySelector<HTMLElement>(".hk-modal-header")!;
385
378
  const bodyBlock = () => document.querySelector<HTMLElement>(".hk-modal-body")!;
386
379
  const riderEl = () => document.querySelector<HTMLElement>(".rider-el")!;
387
- expect(registered).toBe(true);
380
+ expect(lateRegistry).not.toBeNull();
381
+ // Register only AFTER the surface armed — the per-swap timing.
382
+ lateRegistry!.register({ el: riderEl(), counter: true });
388
383
 
389
384
  // The sweep needs real frames from here on.
390
385
  const frames: FrameRequestCallback[] = [];
@@ -422,13 +417,19 @@ describe("HkModal publishes its own fold for the step flow", () => {
422
417
  expect(riderEl().style.transform).toBe("translateY(0px)");
423
418
  expect(header().style.transition).toMatch(/^transform 0\.3s /);
424
419
 
425
- // The landing releases the rides with the frame.
420
+ // The landing releases the rides with the frame — but the CHROME
421
+ // keeps its resident promotion (round 15: demoting at the settle
422
+ // put the phone-GPU raster race back at every landing = black
423
+ // blocks). Only the per-swap registration (the Rider div, not in
424
+ // the frame at arm time) releases its layer.
426
425
  const ev = new Event("transitionend");
427
426
  Object.defineProperty(ev, "propertyName", { value: "clip-path" });
428
427
  frame().dispatchEvent(ev);
429
428
  expect(header().style.transform).toBe("");
430
429
  expect(bodyBlock().style.transform).toBe("");
431
430
  expect(riderEl().style.transform).toBe("");
432
- expect(header().style.willChange).toBe("");
431
+ expect(header().style.willChange).toBe("transform");
432
+ expect(bodyBlock().style.willChange).toBe("transform");
433
+ expect(riderEl().style.willChange).toBe("");
433
434
  });
434
435
  });
@@ -200,6 +200,35 @@ describe("useSizeMorph", () => {
200
200
  expect(h.frame.style.transition).toBe("");
201
201
  });
202
202
 
203
+ it("keeps the resident rider layers through a contamination-guard release", async () => {
204
+ // Round 15 rig finding: the guard's mid-flight release() used to
205
+ // call clearResidentWill(), tearing the chrome promotions down at
206
+ // the swap frame on capped sheets — every later sweep of the same
207
+ // open cycle then promoted at STAGE again (the black-block race).
208
+ // The pin drops (measurement hygiene) but the cycle's layers stay.
209
+ const rider = document.createElement("div");
210
+ const h = mountHarness(600, 560, {
211
+ collectRide: () => [{ el: rider }],
212
+ });
213
+ h.frame.style.setProperty("--hk-sheet-morph", "clip");
214
+ h.content.appendChild(rider);
215
+ h.start();
216
+ expect(rider.style.willChange).toBe("transform");
217
+
218
+ h.setNatural(1728);
219
+ FakeResizeObserver.instances[0]!.callback();
220
+ await settle();
221
+ // The pin dropped…
222
+ expect(h.frame.style.height).toBe("");
223
+ // …but the resident promotion survived the guard, and only the full
224
+ // stop unwinds it.
225
+ expect(rider.style.willChange).toBe("transform");
226
+ expect(h.frame.style.willChange).toBe("clip-path");
227
+ h.stop();
228
+ expect(rider.style.willChange).toBe("");
229
+ expect(h.frame.style.willChange).toBe("");
230
+ });
231
+
203
232
  it("recovers and pins again once a clean measurement returns", async () => {
204
233
  const h = mountHarness(600, 560);
205
234
  h.start();
@@ -874,14 +903,15 @@ describe("useSizeMorph fold riders", () => {
874
903
  h.setNatural(360);
875
904
  h.remeasure();
876
905
  await busFrames(2);
877
- expect(collected).toBe(1);
906
+ // One collection at ARM (resident promotion) + one at the stage.
907
+ expect(collected).toBe(2);
878
908
  // A second growth mid-sweep: the old ride is cleared (atomically
879
909
  // with the interrupted sweep's landing) and re-collected for the new
880
910
  // dance's own stage.
881
911
  h.setNatural(420);
882
912
  h.remeasure();
883
913
  expect(rider.style.transform).toBe("translateY(60px)");
884
- expect(collected).toBe(2);
914
+ expect(collected).toBe(3);
885
915
  // The successor sweep runs and lands on its own terms.
886
916
  await busFrames(2);
887
917
  expect(rider.style.transform).toBe("translateY(0px)");
@@ -1017,3 +1047,104 @@ describe("useSizeMorph fold rider settle handoff", () => {
1017
1047
  h.stop();
1018
1048
  });
1019
1049
  });
1050
+
1051
+ // ── Resident rider promotion (round 15, 2026-09-23 black-block report) ──
1052
+ // The riders' will-change initially landed at STAGE time: three fresh
1053
+ // layer promotions inside the frame's re-raster task, with only the
1054
+ // two-frame warmup to absorb them — on the phone GPU the body block
1055
+ // composited as black tiles through the early sweep (the same raster
1056
+ // race the frame's resident promotion had fixed). The host chrome now
1057
+ // promotes ONCE at arm time and keeps its layers for the whole cycle.
1058
+
1059
+ describe("useSizeMorph resident rider promotion", () => {
1060
+ it("promotes in-frame riders at arm time and keeps them through settles", async () => {
1061
+ const rider = document.createElement("div");
1062
+ const h = mountHarness(300, 300, {
1063
+ collectRide: () => [{ el: rider }],
1064
+ });
1065
+ h.frame.style.setProperty("--hk-sheet-morph", "clip");
1066
+ h.content.appendChild(rider);
1067
+ // Before arming there is no promotion.
1068
+ expect(rider.style.willChange).toBe("");
1069
+ h.start();
1070
+ // Armed: the rider (inside the frame subtree) is promoted once,
1071
+ // together with the frame itself.
1072
+ expect(rider.style.willChange).toBe("transform");
1073
+ expect(h.frame.style.willChange).toBe("clip-path");
1074
+
1075
+ // A full sweep: stage → flip → settle. The rider rides, and the
1076
+ // settle must NOT demote it — the landing crossing a layer boundary
1077
+ // is the raster race that read as black blocks on the phone.
1078
+ h.setNatural(360);
1079
+ h.remeasure();
1080
+ expect(rider.style.transform).toBe("translateY(60px)");
1081
+ await busFrames(2);
1082
+ expect(rider.style.transform).toBe("translateY(0px)");
1083
+ fireTransitionEnd(h.frame, "clip-path");
1084
+ expect(rider.style.transform).toBe("");
1085
+ expect(rider.style.willChange).toBe("transform");
1086
+
1087
+ // The whole open cycle's layerization unwinds at stop/hold.
1088
+ h.stop();
1089
+ expect(rider.style.willChange).toBe("");
1090
+ expect(h.frame.style.willChange).toBe("");
1091
+ });
1092
+
1093
+ it("leaves detached (per-swap) riders on the stage-time promotion path", async () => {
1094
+ // A rider NOT in the frame subtree at arm time — the stepflow's
1095
+ // entering body, registered mid-swap — never becomes resident: its
1096
+ // promotion still lands at stage and still releases at the settle.
1097
+ const resident = document.createElement("div");
1098
+ let perSwap: HTMLElement | null = null;
1099
+ const h = mountHarness(300, 300, {
1100
+ collectRide: () => {
1101
+ const out: Array<{ el: HTMLElement }> = [{ el: resident }];
1102
+ if (perSwap) out.push({ el: perSwap });
1103
+ return out;
1104
+ },
1105
+ });
1106
+ h.frame.style.setProperty("--hk-sheet-morph", "clip");
1107
+ h.content.appendChild(resident);
1108
+ h.start();
1109
+ expect(resident.style.willChange).toBe("transform");
1110
+
1111
+ perSwap = document.createElement("div"); // registered mid-flight, detached
1112
+ h.setNatural(360);
1113
+ h.remeasure();
1114
+ expect(perSwap.style.willChange).toBe("transform");
1115
+ await busFrames(2);
1116
+ fireTransitionEnd(h.frame, "clip-path");
1117
+ expect(perSwap.style.willChange).toBe("");
1118
+ expect(resident.style.willChange).toBe("transform");
1119
+ h.stop();
1120
+ });
1121
+
1122
+ it("unwinds the resident rider layers on unmount", async () => {
1123
+ // The unmount teardown owns its own clearResidentWill (it must not
1124
+ // rely on hold() — an open surface torn down mid-cycle leaves no
1125
+ // other path). Witness: the promoted layer flag outlives the app.
1126
+ const rider = document.createElement("div");
1127
+ const h = mountHarness(300, 300, {
1128
+ collectRide: () => [{ el: rider }],
1129
+ });
1130
+ h.frame.style.setProperty("--hk-sheet-morph", "clip");
1131
+ h.content.appendChild(rider);
1132
+ h.start();
1133
+ expect(rider.style.willChange).toBe("transform");
1134
+ const mount = mounts.splice(-1)[0]!;
1135
+ mount.app.unmount();
1136
+ expect(rider.style.willChange).toBe("");
1137
+ expect(h.frame.style.willChange).toBe("");
1138
+ });
1139
+
1140
+ it("does not promote riders when the surface is not clip-mode", () => {
1141
+ const rider = document.createElement("div");
1142
+ const h = mountHarness(300, 300, {
1143
+ collectRide: () => [{ el: rider }],
1144
+ });
1145
+ h.content.appendChild(rider);
1146
+ h.start();
1147
+ expect(rider.style.willChange).toBe("");
1148
+ h.stop();
1149
+ });
1150
+ });
@@ -235,6 +235,22 @@ export function useSizeMorph(
235
235
  * at open and KEEPING it means step morphs never cross a layer
236
236
  * boundary at all. */
237
237
  let residentWill = false;
238
+ /** Resident RIDER promotions (2026-09-23 round 15, the black-block
239
+ * regression report): the fold riders' own will-change initially
240
+ * landed at STAGE time — three fresh layer promotions (header,
241
+ * subheader, body block) inside the same task as the frame's
242
+ * re-raster, with only the two-frame warmup to absorb them. On the
243
+ * phone GPU the promoted-but-unrastered body block composited as
244
+ * black tiles through the early sweep — the same race the frame's
245
+ * resident promotion had fixed, re-entered from the side. The host
246
+ * chrome riders (whatever collectRide returns at ARM time, when no
247
+ * swap is registered) now promote ONCE at open and keep their layers
248
+ * for the whole cycle; a sweep's settle no longer demotes them, so
249
+ * stage and landing cross no layer boundaries at all. Per-swap
250
+ * riders (the stepflow's entering body — registered mid-flight,
251
+ * small, and opacity-composited by its own fade anyway) keep the
252
+ * stage-time promotion. */
253
+ let residentRideEls: HTMLElement[] = [];
238
254
  /** Identity of the sweep currently staged/folding, echoed to consumers. */
239
255
  let sweepSeq = 0;
240
256
  let activeSweep = 0;
@@ -336,7 +352,12 @@ export function useSizeMorph(
336
352
  // instant; opacity (still listed with its own clock) keeps running.
337
353
  r.el.style.transition = r.ownTransition || "none";
338
354
  r.el.style.transform = "";
339
- r.el.style.willChange = "";
355
+ // Resident-promoted riders (the host chrome, promoted at arm time)
356
+ // KEEP their layers through the settle — demoting here would put
357
+ // the raster race back at every landing (round 15 black blocks).
358
+ if (!residentRideEls.includes(r.el)) {
359
+ r.el.style.willChange = "";
360
+ }
340
361
  }
341
362
  void frame.value?.offsetHeight;
342
363
  for (const r of rideEls) {
@@ -423,8 +444,15 @@ export function useSizeMorph(
423
444
  }
424
445
  }
425
446
 
426
- /** Drop the resident layer promotion (hold / release paths). */
447
+ /** Drop the resident layer promotion (hold / release paths) — the
448
+ * frame's own and the resident riders' together: the whole open
449
+ * cycle's layerization unwinds at once, inside the close
450
+ * choreography's own leave window. */
427
451
  function clearResidentWill(): void {
452
+ for (const el of residentRideEls) {
453
+ el.style.willChange = "";
454
+ }
455
+ residentRideEls = [];
428
456
  if (!residentWill) return;
429
457
  residentWill = false;
430
458
  const f = frame.value;
@@ -551,7 +579,15 @@ export function useSizeMorph(
551
579
  const f = frame.value;
552
580
  if (f) f.style.height = "";
553
581
  stopReveal();
554
- clearResidentWill();
582
+ // Deliberately NOT clearResidentWill(): release() runs both on the
583
+ // full-stop path AND mid-flight from the contamination guard, and
584
+ // the guard's pin drop is a measurement-hygiene event, not a cycle
585
+ // end — dropping the resident layers there re-armed the stage-time
586
+ // raster race for every later sweep of the same open cycle (round
587
+ // 15 rig finding, confirmed by the adversarial round: the capped
588
+ // case tore header/body/frame promotions down at the swap frame).
589
+ // The armed stop path clears them via hold(); a never-armed stop
590
+ // has nothing to clear; unmount owns its own teardown.
555
591
  pinned = 0;
556
592
  }
557
593
 
@@ -799,11 +835,30 @@ export function useSizeMorph(
799
835
  // Resident promotion on clip-mode surfaces (see residentWill): the
800
836
  // layer crosses no boundary during later step morphs. Applied here,
801
837
  // at the open edge — one promotion per open cycle instead of one
802
- // per resize.
838
+ // per resize. The HOST CHROME RIDERS promote here too (see
839
+ // residentRideEls): collectRide at arm time returns exactly the
840
+ // chrome (no swap is registered mid-open), and their layers then
841
+ // live for the whole cycle — a sweep's stage promotes nothing but
842
+ // the small per-swap riders, so the phone GPU never rasterizes a
843
+ // big layer inside the morph window it cannot finish in time.
803
844
  const f0 = frame.value;
804
845
  if (f0 && clipMode(f0)) {
805
846
  f0.style.willChange = "clip-path";
806
847
  residentWill = true;
848
+ if (options.collectRide) {
849
+ residentRideEls = options.collectRide()
850
+ .map((entry) => entry.el)
851
+ .filter((el) => {
852
+ try {
853
+ return frame.value?.contains(el) ?? false;
854
+ } catch {
855
+ return false;
856
+ }
857
+ });
858
+ for (const el of residentRideEls) {
859
+ el.style.willChange = "transform";
860
+ }
861
+ }
807
862
  }
808
863
  if (typeof ResizeObserver === "undefined" || !content.value) {
809
864
  remeasure();