@celestia-island/hikari 0.55.38 → 0.55.40
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
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source contract for the breadcrumb strip's blur stand-down (2026-09-21
|
|
3
|
+
* chest field report, round 5 — the "content blinks once" residual).
|
|
4
|
+
*
|
|
5
|
+
* The strip floats over every popup band, so its backdrop-filter
|
|
6
|
+
* re-samples the whole blurred band each time the sheet behind it
|
|
7
|
+
* resizes or swaps content; on the phone GPU that re-filter read as a
|
|
8
|
+
* one-shot flash per wizard step. The family pattern (HkModal /
|
|
9
|
+
* HkDrawer / HkPopover) is a host-tunable blur knob that defaults to
|
|
10
|
+
* NONE on ≤767px — the strip was the last surface without it.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, expect, it } from "vitest";
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
|
|
17
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const src = readFileSync(join(here, "HkModalBreadcrumb.scss"), "utf-8");
|
|
19
|
+
|
|
20
|
+
/** Brace-aware `@media <query>` block extractor. */
|
|
21
|
+
function mediaBlocks(source: string, query: string): string[] {
|
|
22
|
+
const blocks: string[] = [];
|
|
23
|
+
let from = 0;
|
|
24
|
+
for (;;) {
|
|
25
|
+
const at = source.indexOf(`@media ${query}`, from);
|
|
26
|
+
if (at < 0) break;
|
|
27
|
+
const open = source.indexOf("{", at);
|
|
28
|
+
let depth = 0;
|
|
29
|
+
let i = open;
|
|
30
|
+
for (; i < source.length; i++) {
|
|
31
|
+
if (source[i] === "{") depth++;
|
|
32
|
+
else if (source[i] === "}") {
|
|
33
|
+
depth--;
|
|
34
|
+
if (depth === 0) break;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
blocks.push(source.slice(at, i + 1));
|
|
38
|
+
from = i + 1;
|
|
39
|
+
}
|
|
40
|
+
return blocks;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("HkModalBreadcrumb blur contract", () => {
|
|
44
|
+
it("keeps the base blur host-tunable through the family knob", () => {
|
|
45
|
+
const rule = src.match(/\.hk-modal-breadcrumb\s*\{[^}]*\}/)![0]!;
|
|
46
|
+
expect(rule).toContain(
|
|
47
|
+
"backdrop-filter: var(--hk-modal-breadcrumb-blur, blur(var(--blur-md)));",
|
|
48
|
+
);
|
|
49
|
+
// No naked blur declaration left on the strip itself.
|
|
50
|
+
expect(rule).not.toMatch(/backdrop-filter:\s*blur\(/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("stands the blur down on ≤767px by default", () => {
|
|
54
|
+
const blocks = mediaBlocks(src, "(max-width: 767px)");
|
|
55
|
+
expect(blocks.length).toBe(1);
|
|
56
|
+
expect(blocks[0]).toContain(".hk-modal-breadcrumb");
|
|
57
|
+
expect(blocks[0]).toContain(
|
|
58
|
+
"backdrop-filter: var(--hk-modal-breadcrumb-blur-mobile, none);",
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -17,7 +17,13 @@
|
|
|
17
17
|
border: 1px solid var(--hk-breadcrumb-border, rgba(0, 0, 0, 0.06));
|
|
18
18
|
border-radius: var(--radius-md, 0.5rem);
|
|
19
19
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
|
|
20
|
-
|
|
20
|
+
/* Family blur pattern (HkModal/HkDrawer/HkPopover): host-tunable, and
|
|
21
|
+
stood down on phones — the strip floats over every popup, so its
|
|
22
|
+
backdrop-filter re-samples the whole blurred band each time the sheet
|
|
23
|
+
behind it resizes or swaps content. On the phone GPU that re-filter
|
|
24
|
+
read as a one-shot flash on every wizard step change (2026-09-21
|
|
25
|
+
chest report, round 5). */
|
|
26
|
+
backdrop-filter: var(--hk-modal-breadcrumb-blur, blur(var(--blur-md)));
|
|
21
27
|
pointer-events: none;
|
|
22
28
|
user-select: none;
|
|
23
29
|
animation: hk-modal-breadcrumb-in var(--duration-fast, 0.15s) cubic-bezier(0.16, 1, 0.3, 1);
|
|
@@ -228,3 +234,13 @@
|
|
|
228
234
|
animation: none;
|
|
229
235
|
}
|
|
230
236
|
}
|
|
237
|
+
|
|
238
|
+
/* Phone stand-down for the strip's blur (see the base rule): the default
|
|
239
|
+
* is NONE on ≤767px; a host that explicitly wants the finish back can set
|
|
240
|
+
* --hk-modal-breadcrumb-blur-mobile (the same knob family the modal,
|
|
241
|
+
* drawer and popover sheets already expose). */
|
|
242
|
+
@media (max-width: 767px) {
|
|
243
|
+
.hk-modal-breadcrumb {
|
|
244
|
+
backdrop-filter: var(--hk-modal-breadcrumb-blur-mobile, none);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
@@ -290,19 +290,26 @@ describe("useSizeMorph clip reveal", () => {
|
|
|
290
290
|
h.frame.style.setProperty("--hk-sheet-morph", "clip");
|
|
291
291
|
h.start();
|
|
292
292
|
expect(h.frame.style.height).toBe("300px");
|
|
293
|
+
// Resident promotion: clip-mode frames carry will-change from the
|
|
294
|
+
// arm moment, not per sweep (2026-09-21 round-5 report: a per-sweep
|
|
295
|
+
// promotion read as a one-frame see-through).
|
|
296
|
+
expect(h.frame.style.willChange).toBe("clip-path");
|
|
293
297
|
|
|
294
298
|
h.setNatural(360);
|
|
295
299
|
FakeResizeObserver.instances[0]!.callback();
|
|
296
300
|
await settle();
|
|
297
301
|
// The pin landed at the new height with no height animation staged.
|
|
298
302
|
expect(h.frame.style.height).toBe("360px");
|
|
299
|
-
// The sweep runs: end-state clip
|
|
303
|
+
// The sweep runs: end-state clip (promotion stays resident).
|
|
300
304
|
expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
|
|
301
305
|
expect(h.frame.style.willChange).toBe("clip-path");
|
|
302
306
|
expect(h.frame.style.transition).toBe("");
|
|
303
307
|
|
|
304
308
|
fireTransitionEnd(h.frame, "clip-path");
|
|
305
309
|
expect(h.frame.style.clipPath).toBe("");
|
|
310
|
+
// Still resident until stop/hold.
|
|
311
|
+
expect(h.frame.style.willChange).toBe("clip-path");
|
|
312
|
+
h.stop();
|
|
306
313
|
expect(h.frame.style.willChange).toBe("");
|
|
307
314
|
});
|
|
308
315
|
|
|
@@ -313,14 +320,12 @@ describe("useSizeMorph clip reveal", () => {
|
|
|
313
320
|
|
|
314
321
|
h.setNatural(360);
|
|
315
322
|
h.remeasure();
|
|
316
|
-
// Staged synchronously — new pin, start inset
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
// as black tiles).
|
|
323
|
+
// Staged synchronously — new pin, start inset — but NO sweep yet:
|
|
324
|
+
// the reveal must let the resident layer raster the resized box
|
|
325
|
+
// first (2026-09-21 chest report — a same-task sweep outran the
|
|
326
|
+
// raster thread and the revealed band composited as black tiles).
|
|
321
327
|
expect(h.frame.style.height).toBe("360px");
|
|
322
328
|
expect(h.frame.style.clipPath).toBe("inset(60px 0 0 0 round 0px 0px 0px 0px)");
|
|
323
|
-
expect(h.frame.style.willChange).toBe("clip-path");
|
|
324
329
|
|
|
325
330
|
// The hold is TWO frames, not one: after the first frame the staged
|
|
326
331
|
// start inset must still be in place (a one-frame warmup would have
|
|
@@ -332,7 +337,7 @@ describe("useSizeMorph clip reveal", () => {
|
|
|
332
337
|
expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
|
|
333
338
|
fireTransitionEnd(h.frame, "clip-path");
|
|
334
339
|
expect(h.frame.style.clipPath).toBe("");
|
|
335
|
-
expect(h.frame.style.willChange).toBe("");
|
|
340
|
+
expect(h.frame.style.willChange).toBe("clip-path");
|
|
336
341
|
});
|
|
337
342
|
|
|
338
343
|
it("reports the sweep to the animation bus only once it starts", async () => {
|
|
@@ -396,29 +401,100 @@ describe("useSizeMorph clip reveal", () => {
|
|
|
396
401
|
expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
|
|
397
402
|
});
|
|
398
403
|
|
|
399
|
-
it("
|
|
404
|
+
it("conceals shrink through clip-path and re-pins atomically at the end", async () => {
|
|
400
405
|
const h = mountHarness(400);
|
|
401
406
|
h.frame.style.setProperty("--hk-sheet-morph", "clip");
|
|
402
407
|
h.start();
|
|
403
408
|
|
|
404
|
-
// Shrink
|
|
409
|
+
// Shrink CONCEALS (round-5 report: the snap read as "no animation"):
|
|
410
|
+
// the box keeps the OLD pin while the top edge folds down through
|
|
411
|
+
// the closing inset — the pin swap lands only at the sweep's end.
|
|
405
412
|
h.setNatural(320);
|
|
406
|
-
|
|
413
|
+
h.remeasure();
|
|
414
|
+
// Mid-warmup: the explicit inset(0) start holds (regression
|
|
415
|
+
// contract — never a cleared/none start).
|
|
416
|
+
await busFrames(1);
|
|
417
|
+
expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
|
|
418
|
+
expect(h.frame.style.height).toBe("400px");
|
|
407
419
|
await settle();
|
|
420
|
+
// Sweep in flight: clip folding toward inset(80px), height still
|
|
421
|
+
// the old pin.
|
|
422
|
+
expect(h.frame.style.clipPath).toBe("inset(80px 0 0 0 round 0px 0px 0px 0px)");
|
|
423
|
+
expect(h.frame.style.height).toBe("400px");
|
|
424
|
+
// The sweep ends: atomic re-pin — height jumps to the target with
|
|
425
|
+
// the clip cleared in one transition-off task (visually a no-op).
|
|
426
|
+
fireTransitionEnd(h.frame, "clip-path");
|
|
408
427
|
expect(h.frame.style.height).toBe("320px");
|
|
409
428
|
expect(h.frame.style.clipPath).toBe("");
|
|
410
|
-
expect(h.frame.style.willChange).toBe("");
|
|
411
429
|
|
|
412
430
|
// Sub-threshold growth (2px < REVEAL_MIN_PX): snaps, no reveal.
|
|
413
431
|
h.setNatural(322);
|
|
414
432
|
h.remeasure();
|
|
415
433
|
expect(h.frame.style.height).toBe("322px");
|
|
416
434
|
expect(h.frame.style.clipPath).toBe("");
|
|
435
|
+
|
|
436
|
+
// Sub-threshold shrink (−2px): snaps too, no conceal.
|
|
437
|
+
h.setNatural(320);
|
|
438
|
+
h.remeasure();
|
|
439
|
+
expect(h.frame.style.height).toBe("320px");
|
|
440
|
+
expect(h.frame.style.clipPath).toBe("");
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it("lands an interrupted conceal atomically on the next dance", async () => {
|
|
444
|
+
const h = mountHarness(400);
|
|
445
|
+
h.frame.style.setProperty("--hk-sheet-morph", "clip");
|
|
446
|
+
h.start();
|
|
447
|
+
|
|
448
|
+
h.setNatural(320);
|
|
449
|
+
h.remeasure();
|
|
450
|
+
await settle();
|
|
451
|
+
expect(h.frame.style.clipPath).toBe("inset(80px 0 0 0 round 0px 0px 0px 0px)");
|
|
452
|
+
expect(h.frame.style.height).toBe("400px");
|
|
453
|
+
|
|
454
|
+
// A growth lands mid-conceal: the teardown must land the target pin
|
|
455
|
+
// ATOMICALLY (clearing the clip alone would pop the box back to
|
|
456
|
+
// full height for a frame), then the new reveal stages from the
|
|
457
|
+
// CONCEALED height (360 − 320 = 40px).
|
|
458
|
+
h.setNatural(360);
|
|
459
|
+
h.remeasure();
|
|
460
|
+
expect(h.frame.style.height).toBe("360px");
|
|
461
|
+
expect(h.frame.style.clipPath).toBe("inset(40px 0 0 0 round 0px 0px 0px 0px)");
|
|
462
|
+
await settle();
|
|
463
|
+
expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it("lands the sweep via the watchdog when transitionend never fires", async () => {
|
|
467
|
+
const h = mountHarness(400);
|
|
468
|
+
h.frame.style.setProperty("--hk-sheet-morph", "clip");
|
|
469
|
+
h.start();
|
|
470
|
+
|
|
471
|
+
h.setNatural(320);
|
|
472
|
+
FakeResizeObserver.instances[0]!.callback();
|
|
473
|
+
await settle();
|
|
474
|
+
expect(h.frame.style.clipPath).toBe("inset(80px 0 0 0 round 0px 0px 0px 0px)");
|
|
475
|
+
expect(h.frame.style.height).toBe("400px");
|
|
476
|
+
|
|
477
|
+
// Round-6 regression: a WebView that never delivers clip-path
|
|
478
|
+
// transitionend used to freeze the frame here forever. The watchdog
|
|
479
|
+
// (duration + 350ms grace; happy-dom resolves no computed duration,
|
|
480
|
+
// so the 150ms fallback applies -> ~500ms) must land the same
|
|
481
|
+
// atomic re-pin.
|
|
482
|
+
await new Promise((resolve) => setTimeout(resolve, 620));
|
|
483
|
+
expect(h.frame.style.height).toBe("320px");
|
|
484
|
+
expect(h.frame.style.clipPath).toBe("");
|
|
485
|
+
expect(h.frame.style.willChange).toBe("clip-path");
|
|
486
|
+
|
|
487
|
+
// The watchdog is disarmed on the normal close: a later unrelated
|
|
488
|
+
// transitionend must be a no-op (no double landing, no throw).
|
|
489
|
+
fireTransitionEnd(h.frame, "clip-path");
|
|
490
|
+
expect(h.frame.style.height).toBe("320px");
|
|
417
491
|
});
|
|
418
492
|
|
|
419
493
|
it("never clips without the mode flag (desktop height morph intact)", async () => {
|
|
420
494
|
const h = mountHarness(300);
|
|
421
495
|
h.start();
|
|
496
|
+
// No resident promotion either — the desktop surface never promotes.
|
|
497
|
+
expect(h.frame.style.willChange).toBe("");
|
|
422
498
|
|
|
423
499
|
h.setNatural(400);
|
|
424
500
|
FakeResizeObserver.instances[0]!.callback();
|
|
@@ -426,6 +502,13 @@ describe("useSizeMorph clip reveal", () => {
|
|
|
426
502
|
expect(h.frame.style.height).toBe("400px");
|
|
427
503
|
expect(h.frame.style.clipPath).toBe("");
|
|
428
504
|
expect(h.frame.style.willChange).toBe("");
|
|
505
|
+
|
|
506
|
+
// Desktop shrink keeps the plain height morph (no conceal).
|
|
507
|
+
h.setNatural(240);
|
|
508
|
+
FakeResizeObserver.instances[0]!.callback();
|
|
509
|
+
await settle();
|
|
510
|
+
expect(h.frame.style.height).toBe("240px");
|
|
511
|
+
expect(h.frame.style.clipPath).toBe("");
|
|
429
512
|
});
|
|
430
513
|
|
|
431
514
|
it("clears an in-flight reveal when a new dance starts", async () => {
|
|
@@ -486,22 +569,25 @@ describe("useSizeMorph clip reveal", () => {
|
|
|
486
569
|
h.remeasure();
|
|
487
570
|
expect(h.frame.style.clipPath).not.toBe("");
|
|
488
571
|
|
|
489
|
-
// A SHRINK lands before the reveal
|
|
490
|
-
//
|
|
491
|
-
//
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
//
|
|
572
|
+
// A SHRINK lands before the reveal's warmup fired: the teardown
|
|
573
|
+
// drops the staged reveal (clip off — the reveal branch's teardown
|
|
574
|
+
// never pops the box, it already sits at its pin) and the new dance
|
|
575
|
+
// CONCEALS toward 310: the box keeps the 380 pin while the edge
|
|
576
|
+
// folds, so no height change is visible until the atomic re-pin.
|
|
577
|
+
// (R1 mutation M1 lineage: without the dance-start teardown a stale
|
|
578
|
+
// clip rode the next morph and lingered at rest.)
|
|
496
579
|
h.setNatural(310);
|
|
497
580
|
h.remeasure();
|
|
498
|
-
expect(h.frame.style.height).toBe("
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
581
|
+
expect(h.frame.style.height).toBe("380px");
|
|
582
|
+
// Explicit inset(0) start — the round-6 regression contract: a
|
|
583
|
+
// cleared (none) start point made Chromium skip the clip transition
|
|
584
|
+
// AND its transitionend, freezing the sheet clipped at its old pin.
|
|
585
|
+
expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
|
|
502
586
|
await settle();
|
|
587
|
+
expect(h.frame.style.clipPath).toBe("inset(70px 0 0 0 round 0px 0px 0px 0px)");
|
|
588
|
+
fireTransitionEnd(h.frame, "clip-path");
|
|
589
|
+
expect(h.frame.style.height).toBe("310px");
|
|
503
590
|
expect(h.frame.style.clipPath).toBe("");
|
|
504
|
-
expect(h.frame.style.willChange).toBe("");
|
|
505
591
|
});
|
|
506
592
|
|
|
507
593
|
it("releases the clip state on stop so the leave animation owns the frame", async () => {
|
|
@@ -96,24 +96,27 @@ export interface SizeMorphOptions {
|
|
|
96
96
|
* pin chases the target with the CSS transition — bounded, one-shot
|
|
97
97
|
* choreography, not an infinite per-frame animation.
|
|
98
98
|
*
|
|
99
|
-
*
|
|
100
|
-
* `--hk-sheet-morph: clip` in CSS)
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
* but paint/compositor-level: no per-frame layout and no per-frame
|
|
99
|
+
* Size morphs on clip-mode surfaces (the mobile sheet docking, flagged
|
|
100
|
+
* `--hk-sheet-morph: clip` in CSS) ride paint-only clip-path in BOTH
|
|
101
|
+
* directions — same duration/ease tokens as the height transition, same
|
|
102
|
+
* "content rides rigidly" grammar as the modal unveil, but
|
|
103
|
+
* compositor-level: no per-frame layout and no per-frame
|
|
105
104
|
* backdrop-filter re-raster over the resizing fixed layer (the mobile
|
|
106
|
-
* patchy-flicker source, 2026-09-15 chest report).
|
|
107
|
-
*
|
|
108
|
-
*
|
|
105
|
+
* patchy-flicker source, 2026-09-15 chest report). Growth REVEALS: the
|
|
106
|
+
* new pin lands instantly and the top edge sweeps up through the staged
|
|
107
|
+
* inset — never in the staging task, a two-frame warmup
|
|
108
|
+
* (REVEAL_WARMUP_FRAMES) lets the layer's raster land first
|
|
109
109
|
* (2026-09-21 chest report — same-task starts revealed black tiles).
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
110
|
+
* Shrink CONCEALS: the box keeps its old pin while the top edge folds
|
|
111
|
+
* down through the closing inset, and an atomic re-pin (transition-off
|
|
112
|
+
* height swap) lands the target when the sweep ends — restoring the
|
|
113
|
+
* shrink animation the clip-path-only transition list had lost
|
|
114
|
+
* (2026-09-21 chest report, round 5) without reintroducing a frame of
|
|
115
|
+
* per-frame layout. The layer promotion is RESIDENT for the whole arm
|
|
116
|
+
* cycle (applied at start(), cleared on hold/release): promoting at the
|
|
117
|
+
* step-change moment cost a one-frame see-through as the old layer died
|
|
118
|
+
* before the new one rastered (same report).
|
|
119
|
+
* Height-mode surfaces (desktop) keep the height morph unchanged.
|
|
117
120
|
*
|
|
118
121
|
* Scheduling rides the shared animation context
|
|
119
122
|
* (`runtime/animationBus`): the measurement hop and the reveal warmup
|
|
@@ -146,19 +149,61 @@ export function useSizeMorph(
|
|
|
146
149
|
* bodies that overflow at rest, plus subpixel slack. See the guard in
|
|
147
150
|
* remeasure(). */
|
|
148
151
|
let chromeAllowance = CHROME_ALLOWANCE_FLOOR + CHROME_ALLOWANCE_SLACK;
|
|
149
|
-
/** In-flight clip
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
152
|
+
/** In-flight clip morph (clip-mode size change): the frame whose inline
|
|
153
|
+
* clip-path must come off again once the sweep lands, the listener
|
|
154
|
+
* that does it, the warmup one-shot that starts the sweep, the sweep's
|
|
155
|
+
* bus transition report — and, for a CONCEAL (shrink), the target
|
|
156
|
+
* height the atomic re-pin lands when the sweep ends. */
|
|
153
157
|
let revealEl: HTMLElement | null = null;
|
|
154
158
|
let revealEnd: ((ev: Event) => void) | null = null;
|
|
155
159
|
let revealWarmup: AnimationHandle | null = null;
|
|
156
160
|
let revealReport: AnimationHandle | null = null;
|
|
161
|
+
let revealDir: "reveal" | "conceal" | null = null;
|
|
162
|
+
let concealTo: number | null = null;
|
|
163
|
+
/** Sweep watchdog: transitionend is the normal closer, but a lost
|
|
164
|
+
* event (a WebView that skips clip-path end events entirely — the
|
|
165
|
+
* 2026-09-21 round-6 regression: a none→inset() start point made
|
|
166
|
+
* Chromium drop the transition AND its events, freezing the sheet
|
|
167
|
+
* clipped at its old pin) must not freeze the frame forever. The
|
|
168
|
+
* timer fires the exact same landing path and is disarmed on every
|
|
169
|
+
* normal close. */
|
|
170
|
+
let revealWatchdog: ReturnType<typeof setTimeout> | null = null;
|
|
171
|
+
/** Resident layer promotion on clip-mode surfaces: applied at arm time
|
|
172
|
+
* and cleared on hold/release. Promoting at the STEP-change moment
|
|
173
|
+
* (0.55.38's per-sweep will-change) destroyed the old layer one frame
|
|
174
|
+
* before the new one had rastered — on the phone GPU the sheet read
|
|
175
|
+
* as a one-frame see-through flash exactly when the raster race had
|
|
176
|
+
* just been fixed (2026-09-21 chest report, round 5). Promoting once
|
|
177
|
+
* at open and KEEPING it means step morphs never cross a layer
|
|
178
|
+
* boundary at all. */
|
|
179
|
+
let residentWill = false;
|
|
157
180
|
|
|
158
|
-
/**
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
181
|
+
/** Land a conceal atomically: pin the target height and clear the clip
|
|
182
|
+
* in one transition-disabled task. Called from the sweep's end, from
|
|
183
|
+
* stopReveal() when a conceal is interrupted, and from hold/release.
|
|
184
|
+
* Visually a no-op: the box top is exactly where the clip edge sits,
|
|
185
|
+
* so the pin swap paints nothing. */
|
|
186
|
+
function finishConceal(f: HTMLElement): void {
|
|
187
|
+
const inlineTransition = f.style.transition;
|
|
188
|
+
f.style.transition = "none";
|
|
189
|
+
if (concealTo != null) {
|
|
190
|
+
f.style.height = `${concealTo}px`;
|
|
191
|
+
pinned = concealTo;
|
|
192
|
+
}
|
|
193
|
+
f.style.clipPath = "";
|
|
194
|
+
void f.offsetHeight;
|
|
195
|
+
f.style.transition = inlineTransition;
|
|
196
|
+
concealTo = null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Tear down an in-flight clip morph: cancel the pending warmup and
|
|
200
|
+
* the bus report, drop the listener, and land the frame in its rest
|
|
201
|
+
* state — an interrupted conceal re-pins its target atomically
|
|
202
|
+
* (clearing the clip alone would pop the box back to full height for
|
|
203
|
+
* one frame); a reveal just clears the clip. The resident
|
|
204
|
+
* will-change is NOT touched here (it belongs to the arm cycle, see
|
|
205
|
+
* residentWill). Safe to call when nothing is running (every dance
|
|
206
|
+
* start, stop, and unmount). */
|
|
162
207
|
function stopReveal(): void {
|
|
163
208
|
if (revealWarmup) {
|
|
164
209
|
revealWarmup.disconnect();
|
|
@@ -168,15 +213,32 @@ export function useSizeMorph(
|
|
|
168
213
|
revealReport.disconnect();
|
|
169
214
|
revealReport = null;
|
|
170
215
|
}
|
|
216
|
+
if (revealWatchdog) {
|
|
217
|
+
clearTimeout(revealWatchdog);
|
|
218
|
+
revealWatchdog = null;
|
|
219
|
+
}
|
|
171
220
|
if (revealEl && revealEnd) {
|
|
172
221
|
revealEl.removeEventListener("transitionend", revealEnd);
|
|
173
222
|
}
|
|
174
223
|
if (revealEl) {
|
|
175
|
-
|
|
176
|
-
|
|
224
|
+
if (revealDir === "conceal" && concealTo != null) {
|
|
225
|
+
finishConceal(revealEl);
|
|
226
|
+
} else {
|
|
227
|
+
revealEl.style.clipPath = "";
|
|
228
|
+
}
|
|
177
229
|
}
|
|
178
230
|
revealEl = null;
|
|
179
231
|
revealEnd = null;
|
|
232
|
+
revealDir = null;
|
|
233
|
+
concealTo = null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Drop the resident layer promotion (hold / release paths). */
|
|
237
|
+
function clearResidentWill(): void {
|
|
238
|
+
if (!residentWill) return;
|
|
239
|
+
residentWill = false;
|
|
240
|
+
const f = frame.value;
|
|
241
|
+
if (f) f.style.willChange = "";
|
|
180
242
|
}
|
|
181
243
|
|
|
182
244
|
/** The frame's computed clip-transition duration, for the bus report
|
|
@@ -201,13 +263,22 @@ export function useSizeMorph(
|
|
|
201
263
|
|
|
202
264
|
/** Begin the actual sweep: attach the end listener, report the CSS
|
|
203
265
|
* transition to the bus so it keeps beating for the duration, and
|
|
204
|
-
* flip the clip to the
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
|
|
266
|
+
* flip the clip to the sweep's END state under the live transition.
|
|
267
|
+
* A reveal opens the clip (inset(delta)→inset(0), the top edge
|
|
268
|
+
* sweeping up); a conceal closes it (inset(0)→inset(delta), the top
|
|
269
|
+
* edge folding down while the box itself stays pinned at the OLD
|
|
270
|
+
* height — the atomic re-pin lands in finishConceal when the sweep
|
|
271
|
+
* ends). Only ever called from the warmup's last frame — never
|
|
272
|
+
* synchronously from the dance (see REVEAL_WARMUP_FRAMES). */
|
|
273
|
+
function startRevealSweep(
|
|
274
|
+
f: HTMLElement,
|
|
275
|
+
radii: string,
|
|
276
|
+
dir: "reveal" | "conceal",
|
|
277
|
+
insetPx: number,
|
|
278
|
+
): void {
|
|
208
279
|
const onEnd = (ev: Event): void => {
|
|
209
280
|
// transitionend bubbles: a descendant animating its own
|
|
210
|
-
// clip-path must not end the frame's
|
|
281
|
+
// clip-path must not end the frame's morph early.
|
|
211
282
|
if (
|
|
212
283
|
ev.target === f &&
|
|
213
284
|
(ev as TransitionEvent).propertyName === "clip-path"
|
|
@@ -218,8 +289,18 @@ export function useSizeMorph(
|
|
|
218
289
|
f.addEventListener("transitionend", onEnd);
|
|
219
290
|
revealEl = f;
|
|
220
291
|
revealEnd = onEnd;
|
|
221
|
-
|
|
222
|
-
|
|
292
|
+
revealDir = dir;
|
|
293
|
+
const durationMs = transitionDurationMs(f);
|
|
294
|
+
revealReport = reportTransition(durationMs);
|
|
295
|
+
// Watchdog (see revealWatchdog): same landing, forced, if the
|
|
296
|
+
// transitionend never arrives.
|
|
297
|
+
revealWatchdog = setTimeout(() => {
|
|
298
|
+
if (revealEl === f && revealDir === dir) stopReveal();
|
|
299
|
+
}, durationMs + 350);
|
|
300
|
+
f.style.clipPath =
|
|
301
|
+
dir === "reveal"
|
|
302
|
+
? `inset(0px 0 0 0 round ${radii})`
|
|
303
|
+
: `inset(${insetPx}px 0 0 0 round ${radii})`;
|
|
223
304
|
}
|
|
224
305
|
|
|
225
306
|
/** Clip-mode opt-in, owned by CSS: the modal's mobile media block
|
|
@@ -265,6 +346,7 @@ export function useSizeMorph(
|
|
|
265
346
|
const f = frame.value;
|
|
266
347
|
if (f) f.style.height = "";
|
|
267
348
|
stopReveal();
|
|
349
|
+
clearResidentWill();
|
|
268
350
|
pinned = 0;
|
|
269
351
|
}
|
|
270
352
|
|
|
@@ -338,22 +420,36 @@ export function useSizeMorph(
|
|
|
338
420
|
return;
|
|
339
421
|
}
|
|
340
422
|
const next = Math.round(natural);
|
|
341
|
-
const
|
|
342
|
-
// Clip
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
|
|
423
|
+
const delta = next - pinned;
|
|
424
|
+
// Clip morphs (see the composable doc): on clip-mode surfaces BOTH
|
|
425
|
+
// directions ride paint-only clip-path — growth REVEALS (pin the new
|
|
426
|
+
// height outright, then sweep the top edge up through the staged
|
|
427
|
+
// inset) and shrink CONCEALS (keep the old pin, then fold the top
|
|
428
|
+
// edge down; the atomic re-pin lands when the sweep ends). No
|
|
429
|
+
// per-frame layout in either direction; the frame's stylesheet owns
|
|
430
|
+
// the clip-path transition (duration/ease tokens shared with the
|
|
431
|
+
// height transition), so reduced-motion and the global animation
|
|
432
|
+
// switch collapse both exactly like the height morph they govern.
|
|
433
|
+
// First pin, sub-threshold deltas and height-mode surfaces keep the
|
|
434
|
+
// height morph below (desktop stays exactly as it was).
|
|
435
|
+
const reveal = pinned > 0 && delta >= REVEAL_MIN_PX && clipMode(f);
|
|
436
|
+
const conceal = pinned > 0 && delta <= -REVEAL_MIN_PX && clipMode(f);
|
|
352
437
|
let radii = "";
|
|
353
438
|
if (reveal) {
|
|
354
439
|
radii = cornerRadii(f);
|
|
355
440
|
f.style.height = `${next}px`;
|
|
356
|
-
f.style.clipPath = `inset(${
|
|
441
|
+
f.style.clipPath = `inset(${delta}px 0 0 0 round ${radii})`;
|
|
442
|
+
} else if (conceal) {
|
|
443
|
+
radii = cornerRadii(f);
|
|
444
|
+
// The box stays pinned at the OLD height, and the clip start is an
|
|
445
|
+
// EXPLICIT inset(0) — not a cleared inline clip. Chromium does not
|
|
446
|
+
// interpolate clip-path between none and inset(), so a none start
|
|
447
|
+
// made the sweep jump to its end state and never fire
|
|
448
|
+
// transitionend, freezing the sheet clipped at its old pin
|
|
449
|
+
// (2026-09-21 round-6 regression report: "the top half is just cut
|
|
450
|
+
// off and then it stays there"). inset(0)→inset(N) interpolates.
|
|
451
|
+
f.style.height = `${pinned}px`;
|
|
452
|
+
f.style.clipPath = `inset(0px 0 0 0 round ${radii})`;
|
|
357
453
|
} else if (pinned > 0) {
|
|
358
454
|
f.style.height = `${pinned}px`;
|
|
359
455
|
}
|
|
@@ -362,34 +458,47 @@ export function useSizeMorph(
|
|
|
362
458
|
// old visual edge / the height transition starts from the old pin.
|
|
363
459
|
void f.offsetHeight;
|
|
364
460
|
f.style.transition = inlineTransition;
|
|
365
|
-
if (reveal) {
|
|
366
|
-
// Warmup (see REVEAL_WARMUP_FRAMES):
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
//
|
|
461
|
+
if (reveal || conceal) {
|
|
462
|
+
// Warmup (see REVEAL_WARMUP_FRAMES): hold the staged state for two
|
|
463
|
+
// bus frames so the layer's raster lands before the edge moves;
|
|
464
|
+
// the sweep itself starts from the bus. The layer is ALREADY
|
|
465
|
+
// promoted (resident will-change since start()), so the warmup
|
|
466
|
+
// costs no layer churn. Bus one-shots fire even while the bus is
|
|
467
|
+
// parked for reduced motion — they are scheduling primitives, not
|
|
468
|
+
// motion; the motion collapse stays CSS-owned
|
|
469
|
+
// (transition-duration → one frame), so a parked bus still lands
|
|
470
|
+
// the sweep instantly and transitionend cleans up exactly as
|
|
471
|
+
// before.
|
|
373
472
|
revealEl = f;
|
|
374
|
-
|
|
473
|
+
revealDir = reveal ? "reveal" : "conceal";
|
|
474
|
+
concealTo = conceal ? next : null;
|
|
475
|
+
const dir = revealDir;
|
|
476
|
+
const insetPx = Math.abs(delta);
|
|
375
477
|
let framesLeft = REVEAL_WARMUP_FRAMES;
|
|
376
478
|
const armWarmup = (): void => {
|
|
377
479
|
revealWarmup = scheduleFrame(() => {
|
|
378
480
|
revealWarmup = null;
|
|
379
481
|
// Torn down mid-warmup (new dance / hold / stop / unmount).
|
|
380
|
-
if (revealEl !== f) return;
|
|
482
|
+
if (revealEl !== f || revealDir !== dir) return;
|
|
381
483
|
if (--framesLeft > 0) {
|
|
382
484
|
armWarmup();
|
|
383
485
|
return;
|
|
384
486
|
}
|
|
385
|
-
startRevealSweep(f, radii);
|
|
487
|
+
startRevealSweep(f, radii, dir, insetPx);
|
|
386
488
|
});
|
|
387
489
|
};
|
|
388
490
|
armWarmup();
|
|
389
491
|
} else {
|
|
390
492
|
f.style.height = `${next}px`;
|
|
391
493
|
}
|
|
392
|
-
|
|
494
|
+
if (!conceal) {
|
|
495
|
+
// The pin bookkeeping is immediate for reveals (the box already
|
|
496
|
+
// sits at the new height) and snaps (nothing animates). A conceal
|
|
497
|
+
// keeps the OLD pin until finishConceal() lands the target — a
|
|
498
|
+
// mid-flight remeasure must see the still-visual height as its
|
|
499
|
+
// "from".
|
|
500
|
+
pinned = next;
|
|
501
|
+
}
|
|
393
502
|
// Self-heal the allowance on every VALIDATED pin: chrome that grew
|
|
394
503
|
// after calibration (an async footer, a header slot mounting
|
|
395
504
|
// mid-open) updates the baseline instead of tripping the guard on
|
|
@@ -435,6 +544,15 @@ export function useSizeMorph(
|
|
|
435
544
|
// its enter (callers arm in after-enter) and sits at rest, so the
|
|
436
545
|
// frame-vs-content delta is pure chrome.
|
|
437
546
|
calibrate();
|
|
547
|
+
// Resident promotion on clip-mode surfaces (see residentWill): the
|
|
548
|
+
// layer crosses no boundary during later step morphs. Applied here,
|
|
549
|
+
// at the open edge — one promotion per open cycle instead of one
|
|
550
|
+
// per resize.
|
|
551
|
+
const f0 = frame.value;
|
|
552
|
+
if (f0 && clipMode(f0)) {
|
|
553
|
+
f0.style.willChange = "clip-path";
|
|
554
|
+
residentWill = true;
|
|
555
|
+
}
|
|
438
556
|
if (typeof ResizeObserver === "undefined" || !content.value) {
|
|
439
557
|
remeasure();
|
|
440
558
|
return;
|
|
@@ -461,6 +579,9 @@ export function useSizeMorph(
|
|
|
461
579
|
// cycle carries.
|
|
462
580
|
chromeAllowance = CHROME_ALLOWANCE_FLOOR + CHROME_ALLOWANCE_SLACK;
|
|
463
581
|
stopReveal();
|
|
582
|
+
// The leave fold no longer clips; drop the resident promotion with
|
|
583
|
+
// it (start() re-applies on the next open).
|
|
584
|
+
clearResidentWill();
|
|
464
585
|
// Deliberately no release(): the pin stays on the frame so the close
|
|
465
586
|
// fold plays on a stable box, and a reopen interrupt animates from it.
|
|
466
587
|
}
|
|
@@ -479,6 +600,7 @@ export function useSizeMorph(
|
|
|
479
600
|
if (settleTimer) clearTimeout(settleTimer);
|
|
480
601
|
if (raf) raf.disconnect();
|
|
481
602
|
stopReveal();
|
|
603
|
+
clearResidentWill();
|
|
482
604
|
});
|
|
483
605
|
|
|
484
606
|
return { start, stop, hold, remeasure };
|