@celestia-island/hikari 0.55.59 → 0.55.60

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.59",
3
+ "version": "0.55.60",
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",
@@ -451,6 +451,18 @@
451
451
  backdrop-filter: var(--hk-modal-overlay-blur-mobile, none);
452
452
  }
453
453
 
454
+ // Height-morph window (round 20): while the sheet's height animates
455
+ // every frame, a backdrop-filter anywhere in the resizing subtree has
456
+ // to re-render its backdrop surface every frame — the same per-frame
457
+ // surface churn that made promoted layers flash through on phone
458
+ // GPUs, and the 2026-09-15 report's patchy-flicker source. The morph
459
+ // flags the frame, and the sticky step indicator drops its blur for
460
+ // the window (its 95%-opaque background keeps it visually
461
+ // indistinguishable).
462
+ .hk-modal-content[data-hk-morphing] .hk-scroll-pin {
463
+ backdrop-filter: none;
464
+ }
465
+
454
466
  .hk-modal-content {
455
467
  // Phone-sheet transition = clip-path ONLY (2026-09-21, two Xiaomi
456
468
  // WebView chest reports same day):
@@ -1170,11 +1170,19 @@ describe("useSizeMorph deferRemeasure gate (freeze protocol witness)", () => {
1170
1170
 
1171
1171
  describe("useSizeMorph heightMorph (round 18)", () => {
1172
1172
  it("transitions the pin with an inline height transition and cleans up", async () => {
1173
- const h = mountHarness(300, 300);
1173
+ // A resident rider (the host chrome) is promoted at arm time — the
1174
+ // morph must demote it too: the body block is a flex child, so its
1175
+ // box resizes every frame of a height transition.
1176
+ const rider = document.createElement("div");
1177
+ const h = mountHarness(300, 300, {
1178
+ collectRide: () => [{ el: rider }],
1179
+ });
1174
1180
  h.frame.style.setProperty("--hk-sheet-morph", "clip");
1181
+ h.content.appendChild(rider);
1175
1182
  h.start();
1176
1183
  expect(h.frame.style.height).toBe("300px");
1177
1184
  expect(h.frame.style.willChange).toBe("clip-path");
1185
+ expect(rider.style.willChange).toBe("transform");
1178
1186
 
1179
1187
  h.setNatural(400);
1180
1188
  (h as unknown as { heightMorph: (ms: number) => void }).heightMorph(300);
@@ -1184,20 +1192,70 @@ describe("useSizeMorph heightMorph (round 18)", () => {
1184
1192
  expect(h.frame.style.transition).toContain("300ms");
1185
1193
  // No clip staging, no riders — this is a plain height animation.
1186
1194
  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).
1195
+ // EVERY resident promotion is lifted for the animation's duration
1196
+ // (round 19: the frame; round 20: the riders too — the body block
1197
+ // is a flex child, so its box resizes every frame and its
1198
+ // composited texture re-allocates every frame, which is the
1199
+ // content-area flash-through the phone kept reporting). The morph
1200
+ // flag is up so the stylesheet can drop per-frame backdrop-filter
1201
+ // work as well.
1191
1202
  expect(h.frame.style.willChange).toBe("");
1203
+ expect(rider.style.willChange).toBe("");
1204
+ expect(h.frame.hasAttribute("data-hk-morphing")).toBe(true);
1192
1205
 
1193
- // The transitionend cleans the inline override AND restores the
1194
- // resident promotion (the frame is at rest).
1206
+ // The transitionend cleans the inline override, drops the morph
1207
+ // flag and restores every promotion (the frame is at rest).
1195
1208
  const ev = new Event("transitionend");
1196
1209
  Object.defineProperty(ev, "propertyName", { value: "height" });
1197
1210
  h.frame.dispatchEvent(ev);
1198
1211
  expect(h.frame.style.transition).toBe("");
1199
1212
  expect(h.frame.style.willChange).toBe("clip-path");
1213
+ expect(rider.style.willChange).toBe("transform");
1214
+ expect(h.frame.hasAttribute("data-hk-morphing")).toBe(false);
1215
+ h.stop();
1216
+ });
1217
+
1218
+ it("keeps the demotion across overlapping morphs (generation guard)", async () => {
1219
+ // A second navigation can land while the first height animation is
1220
+ // still running. The superseded morph's watchdog must NOT restore
1221
+ // the promotions in the middle of the successor's animation — the
1222
+ // rig caught a re-promoted rider there. Only the newest generation
1223
+ // restores, and it restores the ORIGINAL values (the first
1224
+ // demotion's snapshot), not the empty ones it observed.
1225
+ vi.useFakeTimers();
1226
+ const rider = document.createElement("div");
1227
+ const h = mountHarness(300, 300, {
1228
+ collectRide: () => [{ el: rider }],
1229
+ });
1230
+ h.frame.style.setProperty("--hk-sheet-morph", "clip");
1231
+ h.content.appendChild(rider);
1232
+ h.start();
1233
+ expect(rider.style.willChange).toBe("transform");
1234
+
1235
+ // Morph #1 starts at t=0 (watchdog at 120+350ms).
1236
+ h.setNatural(400);
1237
+ h.heightMorph(120);
1238
+ expect(rider.style.willChange).toBe("");
1239
+ // Morph #2 lands 100ms later (its own watchdog at 570ms).
1240
+ vi.advanceTimersByTime(100);
1241
+ h.setNatural(500);
1242
+ h.heightMorph(120);
1243
+ // #1's watchdog (470ms) fires inside #2's window — and must not
1244
+ // touch the successor's demotion.
1245
+ vi.advanceTimersByTime(380);
1246
+ expect(
1247
+ rider.style.willChange,
1248
+ "the superseded morph must not re-promote mid-animation",
1249
+ ).toBe("");
1250
+ expect(h.frame.hasAttribute("data-hk-morphing")).toBe(true);
1251
+
1252
+ // #2's own window closes: everything comes back.
1253
+ vi.advanceTimersByTime(120);
1254
+ expect(rider.style.willChange).toBe("transform");
1255
+ expect(h.frame.style.willChange).toBe("clip-path");
1256
+ expect(h.frame.hasAttribute("data-hk-morphing")).toBe(false);
1200
1257
  h.stop();
1258
+ vi.useRealTimers();
1201
1259
  });
1202
1260
 
1203
1261
  it("is a no-op for sub-threshold deltas", async () => {
@@ -245,6 +245,17 @@ export function useSizeMorph(
245
245
  * small, and opacity-composited by its own fade anyway) keep the
246
246
  * stage-time promotion. */
247
247
  let residentRideEls: HTMLElement[] = [];
248
+ /** Height-morph promotion snapshot (round 20). Overlapping morphs are
249
+ * possible (a second navigation lands while the first height
250
+ * animation still runs): the FIRST demotion captures the real
251
+ * resident values, later morphs leave the snapshot alone, and only
252
+ * the newest generation's restore puts them back — otherwise a
253
+ * superseded morph's watchdog re-promotes the riders in the middle
254
+ * of the successor's animation (the rig caught exactly that). */
255
+ let morphEpoch = 0;
256
+ let morphDemoted = false;
257
+ let morphSavedFrameWill = "";
258
+ let morphSavedRiderWills: Array<[HTMLElement, string]> = [];
248
259
  /** Identity of the sweep currently staged/folding, echoed to consumers. */
249
260
  let sweepSeq = 0;
250
261
  let activeSweep = 0;
@@ -440,8 +451,14 @@ export function useSizeMorph(
440
451
  /** Drop the resident layer promotion (hold / release paths) — the
441
452
  * frame's own and the resident riders' together: the whole open
442
453
  * cycle's layerization unwinds at once, inside the close
443
- * choreography's own leave window. */
454
+ * choreography's own leave window. A height morph still in flight
455
+ * loses its restore snapshot here too: the cycle is over, so the
456
+ * pending restore must not put the promotions back on a sheet that
457
+ * already unwound them. */
444
458
  function clearResidentWill(): void {
459
+ morphSavedRiderWills = [];
460
+ morphSavedFrameWill = "";
461
+ morphDemoted = false;
445
462
  for (const el of residentRideEls) {
446
463
  el.style.willChange = "";
447
464
  }
@@ -936,36 +953,64 @@ export function useSizeMorph(
936
953
  // 2. Re-establish the OLD pin under no transition, flush it.
937
954
  f.style.height = `${pinned}px`;
938
955
  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;
956
+ // 3. DEMOTE every promoted layer in the sheet for the animation's
957
+ // duration — the frame AND the resident riders. A composited
958
+ // layer whose element changes layout size per-frame re-allocates
959
+ // its GPU texture every frame; on a phone GPU the gap between the
960
+ // old texture being discarded and the new one rastering
961
+ // composites as TRANSPARENT, so the region that layer covers
962
+ // flashes through to the page behind (round-19 report on the
963
+ // frame; round-20 report showed the flicker persisting over
964
+ // exactly the CONTENT area — the body block keeps its resident
965
+ // promotion from round 15 and is a flex child, so its box
966
+ // resizes every frame of a height transition). A backdrop-filter
967
+ // on a resizing subtree is the same class of per-frame surface
968
+ // churn (the sticky step indicator's blur), so the morph also
969
+ // flags the frame for the stylesheet to drop it. With everything
970
+ // demoted the height animation's per-frame paint happens on the
971
+ // main thread, which is slower but never leaves a frame where
972
+ // the sheet's surface is missing.
973
+ const epoch = ++morphEpoch;
974
+ if (!morphDemoted) {
975
+ morphSavedFrameWill = f.style.willChange;
976
+ morphSavedRiderWills = residentRideEls.map((el) => [
977
+ el,
978
+ el.style.willChange,
979
+ ]);
980
+ morphDemoted = true;
981
+ }
950
982
  if (residentWill) {
951
983
  f.style.willChange = "";
952
984
  residentWill = false;
953
985
  }
986
+ for (const el of residentRideEls) {
987
+ el.style.willChange = "";
988
+ }
989
+ f.setAttribute("data-hk-morphing", "");
954
990
  f.style.transition =
955
991
  `height ${durationMs}ms cubic-bezier(0.4, 0, 0.2, 1)`;
956
992
  f.style.height = `${next}px`;
957
993
  pinned = next;
958
994
  // 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
995
+ // list, the morph flag and every promotion together — the frame
996
+ // is at its final geometry and at rest, so the re-promotion's
961
997
  // raster has all the time it needs.
962
998
  const restore = (): void => {
963
999
  f.removeEventListener("transitionend", onEnd);
1000
+ // A superseded morph must not touch the successor's demotion.
1001
+ if (epoch !== morphEpoch) return;
964
1002
  f.style.transition = "";
965
- if (residentWill_ === "clip-path") {
1003
+ f.removeAttribute("data-hk-morphing");
1004
+ if (morphSavedFrameWill === "clip-path") {
966
1005
  f.style.willChange = "clip-path";
967
1006
  residentWill = true;
968
1007
  }
1008
+ for (const [el, will] of morphSavedRiderWills) {
1009
+ el.style.willChange = will;
1010
+ }
1011
+ morphSavedRiderWills = [];
1012
+ morphSavedFrameWill = "";
1013
+ morphDemoted = false;
969
1014
  };
970
1015
  const onEnd = (ev: TransitionEvent): void => {
971
1016
  if (ev.target === f && ev.propertyName === "height") {
@@ -973,10 +1018,7 @@ export function useSizeMorph(
973
1018
  }
974
1019
  };
975
1020
  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
1021
+ setTimeout(restore, durationMs + 350);
980
1022
  }
981
1023
 
982
1024
  return { start, stop, hold, remeasure, heightMorph };