@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
|
@@ -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
|
-
|
|
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
|
-
//
|
|
1188
|
-
//
|
|
1189
|
-
//
|
|
1190
|
-
//
|
|
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
|
|
1194
|
-
//
|
|
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
|
|
940
|
-
//
|
|
941
|
-
// element changes layout size per-frame re-allocates
|
|
942
|
-
// texture every frame
|
|
943
|
-
// texture being discarded and the new one rastering
|
|
944
|
-
// as TRANSPARENT,
|
|
945
|
-
// (round-19 report
|
|
946
|
-
//
|
|
947
|
-
//
|
|
948
|
-
//
|
|
949
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|