@celestia-island/hikari 0.55.38 → 0.55.39
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,68 @@ 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
|
FakeResizeObserver.instances[0]!.callback();
|
|
407
414
|
await settle();
|
|
415
|
+
// Warmup + sweep in flight: clip folding toward inset(80px), height
|
|
416
|
+
// still the old pin.
|
|
417
|
+
expect(h.frame.style.clipPath).toBe("inset(80px 0 0 0 round 0px 0px 0px 0px)");
|
|
418
|
+
expect(h.frame.style.height).toBe("400px");
|
|
419
|
+
// The sweep ends: atomic re-pin — height jumps to the target with
|
|
420
|
+
// the clip cleared in one transition-off task (visually a no-op).
|
|
421
|
+
fireTransitionEnd(h.frame, "clip-path");
|
|
408
422
|
expect(h.frame.style.height).toBe("320px");
|
|
409
423
|
expect(h.frame.style.clipPath).toBe("");
|
|
410
|
-
expect(h.frame.style.willChange).toBe("");
|
|
411
424
|
|
|
412
425
|
// Sub-threshold growth (2px < REVEAL_MIN_PX): snaps, no reveal.
|
|
413
426
|
h.setNatural(322);
|
|
414
427
|
h.remeasure();
|
|
415
428
|
expect(h.frame.style.height).toBe("322px");
|
|
416
429
|
expect(h.frame.style.clipPath).toBe("");
|
|
430
|
+
|
|
431
|
+
// Sub-threshold shrink (−2px): snaps too, no conceal.
|
|
432
|
+
h.setNatural(320);
|
|
433
|
+
h.remeasure();
|
|
434
|
+
expect(h.frame.style.height).toBe("320px");
|
|
435
|
+
expect(h.frame.style.clipPath).toBe("");
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("lands an interrupted conceal atomically on the next dance", async () => {
|
|
439
|
+
const h = mountHarness(400);
|
|
440
|
+
h.frame.style.setProperty("--hk-sheet-morph", "clip");
|
|
441
|
+
h.start();
|
|
442
|
+
|
|
443
|
+
h.setNatural(320);
|
|
444
|
+
h.remeasure();
|
|
445
|
+
await settle();
|
|
446
|
+
expect(h.frame.style.clipPath).toBe("inset(80px 0 0 0 round 0px 0px 0px 0px)");
|
|
447
|
+
expect(h.frame.style.height).toBe("400px");
|
|
448
|
+
|
|
449
|
+
// A growth lands mid-conceal: the teardown must land the target pin
|
|
450
|
+
// ATOMICALLY (clearing the clip alone would pop the box back to
|
|
451
|
+
// full height for a frame), then the new reveal stages from the
|
|
452
|
+
// CONCEALED height (360 − 320 = 40px).
|
|
453
|
+
h.setNatural(360);
|
|
454
|
+
h.remeasure();
|
|
455
|
+
expect(h.frame.style.height).toBe("360px");
|
|
456
|
+
expect(h.frame.style.clipPath).toBe("inset(40px 0 0 0 round 0px 0px 0px 0px)");
|
|
457
|
+
await settle();
|
|
458
|
+
expect(h.frame.style.clipPath).toBe("inset(0px 0 0 0 round 0px 0px 0px 0px)");
|
|
417
459
|
});
|
|
418
460
|
|
|
419
461
|
it("never clips without the mode flag (desktop height morph intact)", async () => {
|
|
420
462
|
const h = mountHarness(300);
|
|
421
463
|
h.start();
|
|
464
|
+
// No resident promotion either — the desktop surface never promotes.
|
|
465
|
+
expect(h.frame.style.willChange).toBe("");
|
|
422
466
|
|
|
423
467
|
h.setNatural(400);
|
|
424
468
|
FakeResizeObserver.instances[0]!.callback();
|
|
@@ -426,6 +470,13 @@ describe("useSizeMorph clip reveal", () => {
|
|
|
426
470
|
expect(h.frame.style.height).toBe("400px");
|
|
427
471
|
expect(h.frame.style.clipPath).toBe("");
|
|
428
472
|
expect(h.frame.style.willChange).toBe("");
|
|
473
|
+
|
|
474
|
+
// Desktop shrink keeps the plain height morph (no conceal).
|
|
475
|
+
h.setNatural(240);
|
|
476
|
+
FakeResizeObserver.instances[0]!.callback();
|
|
477
|
+
await settle();
|
|
478
|
+
expect(h.frame.style.height).toBe("240px");
|
|
479
|
+
expect(h.frame.style.clipPath).toBe("");
|
|
429
480
|
});
|
|
430
481
|
|
|
431
482
|
it("clears an in-flight reveal when a new dance starts", async () => {
|
|
@@ -486,22 +537,22 @@ describe("useSizeMorph clip reveal", () => {
|
|
|
486
537
|
h.remeasure();
|
|
487
538
|
expect(h.frame.style.clipPath).not.toBe("");
|
|
488
539
|
|
|
489
|
-
// A SHRINK lands before the reveal
|
|
490
|
-
//
|
|
491
|
-
//
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
//
|
|
540
|
+
// A SHRINK lands before the reveal's warmup fired: the teardown
|
|
541
|
+
// drops the staged reveal (clip off — the reveal branch's teardown
|
|
542
|
+
// never pops the box, it already sits at its pin) and the new dance
|
|
543
|
+
// CONCEALS toward 310: the box keeps the 380 pin while the edge
|
|
544
|
+
// folds, so no height change is visible until the atomic re-pin.
|
|
545
|
+
// (R1 mutation M1 lineage: without the dance-start teardown a stale
|
|
546
|
+
// clip rode the next morph and lingered at rest.)
|
|
496
547
|
h.setNatural(310);
|
|
497
548
|
h.remeasure();
|
|
498
|
-
expect(h.frame.style.height).toBe("
|
|
549
|
+
expect(h.frame.style.height).toBe("380px");
|
|
499
550
|
expect(h.frame.style.clipPath).toBe("");
|
|
500
|
-
expect(h.frame.style.willChange).toBe("");
|
|
501
|
-
|
|
502
551
|
await settle();
|
|
552
|
+
expect(h.frame.style.clipPath).toBe("inset(70px 0 0 0 round 0px 0px 0px 0px)");
|
|
553
|
+
fireTransitionEnd(h.frame, "clip-path");
|
|
554
|
+
expect(h.frame.style.height).toBe("310px");
|
|
503
555
|
expect(h.frame.style.clipPath).toBe("");
|
|
504
|
-
expect(h.frame.style.willChange).toBe("");
|
|
505
556
|
});
|
|
506
557
|
|
|
507
558
|
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,53 @@ 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
|
+
/** Resident layer promotion on clip-mode surfaces: applied at arm time
|
|
164
|
+
* and cleared on hold/release. Promoting at the STEP-change moment
|
|
165
|
+
* (0.55.38's per-sweep will-change) destroyed the old layer one frame
|
|
166
|
+
* before the new one had rastered — on the phone GPU the sheet read
|
|
167
|
+
* as a one-frame see-through flash exactly when the raster race had
|
|
168
|
+
* just been fixed (2026-09-21 chest report, round 5). Promoting once
|
|
169
|
+
* at open and KEEPING it means step morphs never cross a layer
|
|
170
|
+
* boundary at all. */
|
|
171
|
+
let residentWill = false;
|
|
157
172
|
|
|
158
|
-
/**
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
173
|
+
/** Land a conceal atomically: pin the target height and clear the clip
|
|
174
|
+
* in one transition-disabled task. Called from the sweep's end, from
|
|
175
|
+
* stopReveal() when a conceal is interrupted, and from hold/release.
|
|
176
|
+
* Visually a no-op: the box top is exactly where the clip edge sits,
|
|
177
|
+
* so the pin swap paints nothing. */
|
|
178
|
+
function finishConceal(f: HTMLElement): void {
|
|
179
|
+
const inlineTransition = f.style.transition;
|
|
180
|
+
f.style.transition = "none";
|
|
181
|
+
if (concealTo != null) {
|
|
182
|
+
f.style.height = `${concealTo}px`;
|
|
183
|
+
pinned = concealTo;
|
|
184
|
+
}
|
|
185
|
+
f.style.clipPath = "";
|
|
186
|
+
void f.offsetHeight;
|
|
187
|
+
f.style.transition = inlineTransition;
|
|
188
|
+
concealTo = null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Tear down an in-flight clip morph: cancel the pending warmup and
|
|
192
|
+
* the bus report, drop the listener, and land the frame in its rest
|
|
193
|
+
* state — an interrupted conceal re-pins its target atomically
|
|
194
|
+
* (clearing the clip alone would pop the box back to full height for
|
|
195
|
+
* one frame); a reveal just clears the clip. The resident
|
|
196
|
+
* will-change is NOT touched here (it belongs to the arm cycle, see
|
|
197
|
+
* residentWill). Safe to call when nothing is running (every dance
|
|
198
|
+
* start, stop, and unmount). */
|
|
162
199
|
function stopReveal(): void {
|
|
163
200
|
if (revealWarmup) {
|
|
164
201
|
revealWarmup.disconnect();
|
|
@@ -172,11 +209,24 @@ export function useSizeMorph(
|
|
|
172
209
|
revealEl.removeEventListener("transitionend", revealEnd);
|
|
173
210
|
}
|
|
174
211
|
if (revealEl) {
|
|
175
|
-
|
|
176
|
-
|
|
212
|
+
if (revealDir === "conceal" && concealTo != null) {
|
|
213
|
+
finishConceal(revealEl);
|
|
214
|
+
} else {
|
|
215
|
+
revealEl.style.clipPath = "";
|
|
216
|
+
}
|
|
177
217
|
}
|
|
178
218
|
revealEl = null;
|
|
179
219
|
revealEnd = null;
|
|
220
|
+
revealDir = null;
|
|
221
|
+
concealTo = null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Drop the resident layer promotion (hold / release paths). */
|
|
225
|
+
function clearResidentWill(): void {
|
|
226
|
+
if (!residentWill) return;
|
|
227
|
+
residentWill = false;
|
|
228
|
+
const f = frame.value;
|
|
229
|
+
if (f) f.style.willChange = "";
|
|
180
230
|
}
|
|
181
231
|
|
|
182
232
|
/** The frame's computed clip-transition duration, for the bus report
|
|
@@ -201,13 +251,22 @@ export function useSizeMorph(
|
|
|
201
251
|
|
|
202
252
|
/** Begin the actual sweep: attach the end listener, report the CSS
|
|
203
253
|
* transition to the bus so it keeps beating for the duration, and
|
|
204
|
-
* flip the clip to the
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
|
|
254
|
+
* flip the clip to the sweep's END state under the live transition.
|
|
255
|
+
* A reveal opens the clip (inset(delta)→inset(0), the top edge
|
|
256
|
+
* sweeping up); a conceal closes it (inset(0)→inset(delta), the top
|
|
257
|
+
* edge folding down while the box itself stays pinned at the OLD
|
|
258
|
+
* height — the atomic re-pin lands in finishConceal when the sweep
|
|
259
|
+
* ends). Only ever called from the warmup's last frame — never
|
|
260
|
+
* synchronously from the dance (see REVEAL_WARMUP_FRAMES). */
|
|
261
|
+
function startRevealSweep(
|
|
262
|
+
f: HTMLElement,
|
|
263
|
+
radii: string,
|
|
264
|
+
dir: "reveal" | "conceal",
|
|
265
|
+
insetPx: number,
|
|
266
|
+
): void {
|
|
208
267
|
const onEnd = (ev: Event): void => {
|
|
209
268
|
// transitionend bubbles: a descendant animating its own
|
|
210
|
-
// clip-path must not end the frame's
|
|
269
|
+
// clip-path must not end the frame's morph early.
|
|
211
270
|
if (
|
|
212
271
|
ev.target === f &&
|
|
213
272
|
(ev as TransitionEvent).propertyName === "clip-path"
|
|
@@ -218,8 +277,12 @@ export function useSizeMorph(
|
|
|
218
277
|
f.addEventListener("transitionend", onEnd);
|
|
219
278
|
revealEl = f;
|
|
220
279
|
revealEnd = onEnd;
|
|
280
|
+
revealDir = dir;
|
|
221
281
|
revealReport = reportTransition(transitionDurationMs(f));
|
|
222
|
-
f.style.clipPath =
|
|
282
|
+
f.style.clipPath =
|
|
283
|
+
dir === "reveal"
|
|
284
|
+
? `inset(0px 0 0 0 round ${radii})`
|
|
285
|
+
: `inset(${insetPx}px 0 0 0 round ${radii})`;
|
|
223
286
|
}
|
|
224
287
|
|
|
225
288
|
/** Clip-mode opt-in, owned by CSS: the modal's mobile media block
|
|
@@ -265,6 +328,7 @@ export function useSizeMorph(
|
|
|
265
328
|
const f = frame.value;
|
|
266
329
|
if (f) f.style.height = "";
|
|
267
330
|
stopReveal();
|
|
331
|
+
clearResidentWill();
|
|
268
332
|
pinned = 0;
|
|
269
333
|
}
|
|
270
334
|
|
|
@@ -338,22 +402,31 @@ export function useSizeMorph(
|
|
|
338
402
|
return;
|
|
339
403
|
}
|
|
340
404
|
const next = Math.round(natural);
|
|
341
|
-
const
|
|
342
|
-
// Clip
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
|
|
405
|
+
const delta = next - pinned;
|
|
406
|
+
// Clip morphs (see the composable doc): on clip-mode surfaces BOTH
|
|
407
|
+
// directions ride paint-only clip-path — growth REVEALS (pin the new
|
|
408
|
+
// height outright, then sweep the top edge up through the staged
|
|
409
|
+
// inset) and shrink CONCEALS (keep the old pin, then fold the top
|
|
410
|
+
// edge down; the atomic re-pin lands when the sweep ends). No
|
|
411
|
+
// per-frame layout in either direction; the frame's stylesheet owns
|
|
412
|
+
// the clip-path transition (duration/ease tokens shared with the
|
|
413
|
+
// height transition), so reduced-motion and the global animation
|
|
414
|
+
// switch collapse both exactly like the height morph they govern.
|
|
415
|
+
// First pin, sub-threshold deltas and height-mode surfaces keep the
|
|
416
|
+
// height morph below (desktop stays exactly as it was).
|
|
417
|
+
const reveal = pinned > 0 && delta >= REVEAL_MIN_PX && clipMode(f);
|
|
418
|
+
const conceal = pinned > 0 && delta <= -REVEAL_MIN_PX && clipMode(f);
|
|
352
419
|
let radii = "";
|
|
353
420
|
if (reveal) {
|
|
354
421
|
radii = cornerRadii(f);
|
|
355
422
|
f.style.height = `${next}px`;
|
|
356
|
-
f.style.clipPath = `inset(${
|
|
423
|
+
f.style.clipPath = `inset(${delta}px 0 0 0 round ${radii})`;
|
|
424
|
+
} else if (conceal) {
|
|
425
|
+
radii = cornerRadii(f);
|
|
426
|
+
// The box stays pinned at the OLD height (the visible start state
|
|
427
|
+
// needs no staging — the dance-start teardown already cleared any
|
|
428
|
+
// clip, so the box paints its full current height).
|
|
429
|
+
f.style.height = `${pinned}px`;
|
|
357
430
|
} else if (pinned > 0) {
|
|
358
431
|
f.style.height = `${pinned}px`;
|
|
359
432
|
}
|
|
@@ -362,34 +435,47 @@ export function useSizeMorph(
|
|
|
362
435
|
// old visual edge / the height transition starts from the old pin.
|
|
363
436
|
void f.offsetHeight;
|
|
364
437
|
f.style.transition = inlineTransition;
|
|
365
|
-
if (reveal) {
|
|
366
|
-
// Warmup (see REVEAL_WARMUP_FRAMES):
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
//
|
|
438
|
+
if (reveal || conceal) {
|
|
439
|
+
// Warmup (see REVEAL_WARMUP_FRAMES): hold the staged state for two
|
|
440
|
+
// bus frames so the layer's raster lands before the edge moves;
|
|
441
|
+
// the sweep itself starts from the bus. The layer is ALREADY
|
|
442
|
+
// promoted (resident will-change since start()), so the warmup
|
|
443
|
+
// costs no layer churn. Bus one-shots fire even while the bus is
|
|
444
|
+
// parked for reduced motion — they are scheduling primitives, not
|
|
445
|
+
// motion; the motion collapse stays CSS-owned
|
|
446
|
+
// (transition-duration → one frame), so a parked bus still lands
|
|
447
|
+
// the sweep instantly and transitionend cleans up exactly as
|
|
448
|
+
// before.
|
|
373
449
|
revealEl = f;
|
|
374
|
-
|
|
450
|
+
revealDir = reveal ? "reveal" : "conceal";
|
|
451
|
+
concealTo = conceal ? next : null;
|
|
452
|
+
const dir = revealDir;
|
|
453
|
+
const insetPx = Math.abs(delta);
|
|
375
454
|
let framesLeft = REVEAL_WARMUP_FRAMES;
|
|
376
455
|
const armWarmup = (): void => {
|
|
377
456
|
revealWarmup = scheduleFrame(() => {
|
|
378
457
|
revealWarmup = null;
|
|
379
458
|
// Torn down mid-warmup (new dance / hold / stop / unmount).
|
|
380
|
-
if (revealEl !== f) return;
|
|
459
|
+
if (revealEl !== f || revealDir !== dir) return;
|
|
381
460
|
if (--framesLeft > 0) {
|
|
382
461
|
armWarmup();
|
|
383
462
|
return;
|
|
384
463
|
}
|
|
385
|
-
startRevealSweep(f, radii);
|
|
464
|
+
startRevealSweep(f, radii, dir, insetPx);
|
|
386
465
|
});
|
|
387
466
|
};
|
|
388
467
|
armWarmup();
|
|
389
468
|
} else {
|
|
390
469
|
f.style.height = `${next}px`;
|
|
391
470
|
}
|
|
392
|
-
|
|
471
|
+
if (!conceal) {
|
|
472
|
+
// The pin bookkeeping is immediate for reveals (the box already
|
|
473
|
+
// sits at the new height) and snaps (nothing animates). A conceal
|
|
474
|
+
// keeps the OLD pin until finishConceal() lands the target — a
|
|
475
|
+
// mid-flight remeasure must see the still-visual height as its
|
|
476
|
+
// "from".
|
|
477
|
+
pinned = next;
|
|
478
|
+
}
|
|
393
479
|
// Self-heal the allowance on every VALIDATED pin: chrome that grew
|
|
394
480
|
// after calibration (an async footer, a header slot mounting
|
|
395
481
|
// mid-open) updates the baseline instead of tripping the guard on
|
|
@@ -435,6 +521,15 @@ export function useSizeMorph(
|
|
|
435
521
|
// its enter (callers arm in after-enter) and sits at rest, so the
|
|
436
522
|
// frame-vs-content delta is pure chrome.
|
|
437
523
|
calibrate();
|
|
524
|
+
// Resident promotion on clip-mode surfaces (see residentWill): the
|
|
525
|
+
// layer crosses no boundary during later step morphs. Applied here,
|
|
526
|
+
// at the open edge — one promotion per open cycle instead of one
|
|
527
|
+
// per resize.
|
|
528
|
+
const f0 = frame.value;
|
|
529
|
+
if (f0 && clipMode(f0)) {
|
|
530
|
+
f0.style.willChange = "clip-path";
|
|
531
|
+
residentWill = true;
|
|
532
|
+
}
|
|
438
533
|
if (typeof ResizeObserver === "undefined" || !content.value) {
|
|
439
534
|
remeasure();
|
|
440
535
|
return;
|
|
@@ -461,6 +556,9 @@ export function useSizeMorph(
|
|
|
461
556
|
// cycle carries.
|
|
462
557
|
chromeAllowance = CHROME_ALLOWANCE_FLOOR + CHROME_ALLOWANCE_SLACK;
|
|
463
558
|
stopReveal();
|
|
559
|
+
// The leave fold no longer clips; drop the resident promotion with
|
|
560
|
+
// it (start() re-applies on the next open).
|
|
561
|
+
clearResidentWill();
|
|
464
562
|
// Deliberately no release(): the pin stays on the frame so the close
|
|
465
563
|
// fold plays on a stable box, and a reopen interrupt animates from it.
|
|
466
564
|
}
|
|
@@ -479,6 +577,7 @@ export function useSizeMorph(
|
|
|
479
577
|
if (settleTimer) clearTimeout(settleTimer);
|
|
480
578
|
if (raf) raf.disconnect();
|
|
481
579
|
stopReveal();
|
|
580
|
+
clearResidentWill();
|
|
482
581
|
});
|
|
483
582
|
|
|
484
583
|
return { start, stop, hold, remeasure };
|