@celestia-island/hikari 0.55.53 → 0.55.54
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 +1 -1
- package/src/animation/registerAnimations.ts +7 -0
- package/src/components/HkModal.stepflowswap.test.tsx +243 -97
- package/src/components/HkModal.tsx +55 -22
- package/src/components/HkStepFlow.scss +50 -66
- package/src/components/HkStepFlow.slide.contract.test.ts +70 -75
- package/src/components/HkStepFlow.test.tsx +112 -195
- package/src/components/HkStepFlow.tsx +123 -287
- package/src/components/HkTimeline.scss +133 -1
- package/src/components/HkTimeline.test.tsx +93 -0
- package/src/components/HkTimeline.tsx +59 -2
- package/src/composables/useSizeMorph.test.ts +38 -21
- package/src/composables/useSizeMorph.ts +8 -23
- package/src/runtime/sheetRide.ts +0 -62
package/package.json
CHANGED
|
@@ -84,6 +84,13 @@ registerCssAnimation("hk-image-preview-shimmer", { infinite: true });
|
|
|
84
84
|
registerCssAnimation("hk-progress-indeterminate", { infinite: true });
|
|
85
85
|
// components/HkPasswordSurface.scss — caps-lock warning flash (plays once).
|
|
86
86
|
registerCssAnimation("hk-pwd-flash");
|
|
87
|
+
|
|
88
|
+
// Timeline navigation wipes (HkTimeline, 2026-09-23 round 16): the
|
|
89
|
+
// window-mode cells slide in from the navigation side — one-shot.
|
|
90
|
+
registerCssAnimation("hk-timeline-wipe");
|
|
91
|
+
registerCssAnimation("hk-timeline-wipe-back");
|
|
92
|
+
registerCssAnimation("hk-timeline-wipe-v");
|
|
93
|
+
registerCssAnimation("hk-timeline-wipe-back-v");
|
|
87
94
|
// components/HkPasswordSurface.scss — breathing hint glow (loops forever).
|
|
88
95
|
registerCssAnimation("hk-pwd-breathe", { infinite: true });
|
|
89
96
|
// components/HkPickerPane.scss — date/time picker view drill forward
|
|
@@ -3,7 +3,6 @@ import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
|
3
3
|
|
|
4
4
|
import HkModal from "./HkModal";
|
|
5
5
|
import { STEPFLOW_SWAP_EVENT } from "./HkStepFlow";
|
|
6
|
-
import { useSheetRide } from "../runtime/sheetRide";
|
|
7
6
|
|
|
8
7
|
const SHEET_SWEEP_STAGE_EVENT = "hk-sheet-sweep-stage";
|
|
9
8
|
const SHEET_SWEEP_SETTLE_EVENT = "hk-sheet-sweep-settle";
|
|
@@ -120,8 +119,8 @@ describe("HkModal stepflow swap handoff", () => {
|
|
|
120
119
|
expect(rig.bodyEl()).not.toBeNull();
|
|
121
120
|
expect(rig.morphToken()).toBe("");
|
|
122
121
|
|
|
123
|
-
//
|
|
124
|
-
rig.announceSwap({ delta: 120, durationMs: 150, phase: "
|
|
122
|
+
// The morph window (after the slide settles) owns the height.
|
|
123
|
+
rig.announceSwap({ delta: 120, durationMs: 150, phase: "morph" });
|
|
125
124
|
await nextTick();
|
|
126
125
|
expect(rig.morphToken()).toBe("150ms");
|
|
127
126
|
|
|
@@ -147,43 +146,6 @@ describe("HkModal stepflow swap handoff", () => {
|
|
|
147
146
|
expect(rig.morphToken()).toBe("");
|
|
148
147
|
});
|
|
149
148
|
|
|
150
|
-
it("ignores announcements while the surface is still unfolding", async () => {
|
|
151
|
-
vi.useFakeTimers();
|
|
152
|
-
freezeRaf();
|
|
153
|
-
stubDurations();
|
|
154
|
-
const rig = await mountRig();
|
|
155
|
-
rig.open.value = true;
|
|
156
|
-
// The machine's phase is opening* here: the morph is not armed yet, so
|
|
157
|
-
// an override would be stranded on the frame.
|
|
158
|
-
await vi.advanceTimersByTimeAsync(10);
|
|
159
|
-
if (rig.bodyEl()) {
|
|
160
|
-
rig.announceSwap({ delta: 60, durationMs: 150, phase: "exit" });
|
|
161
|
-
await nextTick();
|
|
162
|
-
}
|
|
163
|
-
expect(rig.morphToken()).toBe("");
|
|
164
|
-
await vi.advanceTimersByTimeAsync(600);
|
|
165
|
-
expect(rig.morphToken()).toBe("");
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
it("drops the override when the modal unmounts mid-sweep", async () => {
|
|
169
|
-
vi.useFakeTimers();
|
|
170
|
-
freezeRaf();
|
|
171
|
-
stubDurations();
|
|
172
|
-
const rig = await mountRig();
|
|
173
|
-
rig.open.value = true;
|
|
174
|
-
await vi.advanceTimersByTimeAsync(600);
|
|
175
|
-
|
|
176
|
-
rig.announceSwap({ delta: 80, durationMs: 150, phase: "exit" });
|
|
177
|
-
await nextTick();
|
|
178
|
-
const frame = rig.frameEl();
|
|
179
|
-
expect(frame?.style.getPropertyValue("--hk-modal-morph-duration")).toBe("150ms");
|
|
180
|
-
|
|
181
|
-
// Unmount with the sweep still in flight: the timer must not outlive
|
|
182
|
-
// the component, and the token must not survive on the detached node.
|
|
183
|
-
for (const app of mounts.splice(0)) app.unmount();
|
|
184
|
-
await nextTick();
|
|
185
|
-
expect(frame?.style.getPropertyValue("--hk-modal-morph-duration")).toBe("");
|
|
186
|
-
});
|
|
187
149
|
});
|
|
188
150
|
|
|
189
151
|
describe("HkModal publishes its own fold for the step flow", () => {
|
|
@@ -327,28 +289,244 @@ describe("HkModal publishes its own fold for the step flow", () => {
|
|
|
327
289
|
expect(settled).toBe(0);
|
|
328
290
|
});
|
|
329
291
|
|
|
330
|
-
it("
|
|
331
|
-
// Round
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
292
|
+
it("freezes the sheet for the slide window and unfreezes at the morph edge", async () => {
|
|
293
|
+
// Round 16 two-window protocol: the "swap" phase holds the sheet's
|
|
294
|
+
// pin (observer-driven remeasures must not chase the flow's cell
|
|
295
|
+
// mid-slide) until the "morph" phase lifts the freeze and measures.
|
|
296
|
+
// The freeze-flag plumbing itself (gate and release of background
|
|
297
|
+
// measurements) is unit-witnessed in useSizeMorph.test.ts.
|
|
298
|
+
vi.useFakeTimers();
|
|
299
|
+
freezeRaf();
|
|
300
|
+
stubDurations();
|
|
301
|
+
const rig = await mountRig();
|
|
302
|
+
rig.open.value = true;
|
|
303
|
+
await vi.advanceTimersByTimeAsync(600);
|
|
304
|
+
expect(rig.morphToken()).toBe("");
|
|
305
|
+
|
|
306
|
+
// The slide window: freeze — no morph token, no measurement.
|
|
307
|
+
rig.announceSwap({ delta: 0, durationMs: 300, phase: "swap" });
|
|
308
|
+
await nextTick();
|
|
309
|
+
expect(rig.morphToken()).toBe("");
|
|
310
|
+
|
|
311
|
+
// The morph window: the freeze lifts and the override lands.
|
|
312
|
+
rig.announceSwap({ delta: 120, durationMs: 300, phase: "morph" });
|
|
313
|
+
await nextTick();
|
|
314
|
+
expect(rig.morphToken()).toBe("300ms");
|
|
315
|
+
await vi.advanceTimersByTimeAsync(700);
|
|
316
|
+
expect(rig.morphToken()).toBe("");
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("ignores announcements while the surface is still unfolding", async () => {
|
|
320
|
+
vi.useFakeTimers();
|
|
321
|
+
freezeRaf();
|
|
322
|
+
stubDurations();
|
|
323
|
+
const rig = await mountRig();
|
|
324
|
+
rig.open.value = true;
|
|
325
|
+
// The machine's phase is opening* here: the morph is not armed yet, so
|
|
326
|
+
// an override would be stranded on the frame.
|
|
327
|
+
await vi.advanceTimersByTimeAsync(10);
|
|
328
|
+
if (rig.bodyEl()) {
|
|
329
|
+
rig.announceSwap({ delta: 60, durationMs: 150, phase: "exit" });
|
|
330
|
+
await nextTick();
|
|
331
|
+
}
|
|
332
|
+
expect(rig.morphToken()).toBe("");
|
|
333
|
+
await vi.advanceTimersByTimeAsync(600);
|
|
334
|
+
expect(rig.morphToken()).toBe("");
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("drops the override when the modal unmounts mid-sweep", async () => {
|
|
336
338
|
vi.useFakeTimers();
|
|
337
339
|
freezeRaf();
|
|
340
|
+
stubDurations();
|
|
341
|
+
const rig = await mountRig();
|
|
342
|
+
rig.open.value = true;
|
|
343
|
+
await vi.advanceTimersByTimeAsync(600);
|
|
344
|
+
|
|
345
|
+
rig.announceSwap({ delta: 80, durationMs: 150, phase: "exit" });
|
|
346
|
+
await nextTick();
|
|
347
|
+
const frame = rig.frameEl()!;
|
|
348
|
+
expect(frame?.style.getPropertyValue("--hk-modal-morph-duration")).toBe("150ms");
|
|
349
|
+
|
|
350
|
+
// Unmount with the sweep still in flight: the timer must not outlive
|
|
351
|
+
// the component, and the token must not survive on the detached node.
|
|
352
|
+
for (const app of mounts.splice(0)) app.unmount();
|
|
353
|
+
await nextTick();
|
|
354
|
+
expect(frame?.style.getPropertyValue("--hk-modal-morph-duration")).toBe("");
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
describe("HkModal rides its chrome and gates the slide window", () => {
|
|
359
|
+
/** Box model: the frame hugs its content up to `cap`; the content probe
|
|
360
|
+
* reports `content` plus the chrome the calibration measures. */
|
|
361
|
+
let content = 0;
|
|
362
|
+
let cap = Number.POSITIVE_INFINITY;
|
|
363
|
+
const realOffsetHeight = Object.getOwnPropertyDescriptor(
|
|
364
|
+
HTMLElement.prototype,
|
|
365
|
+
"offsetHeight",
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
function stubBox(initialContent: number, capPx: number): void {
|
|
369
|
+
content = initialContent;
|
|
370
|
+
cap = capPx;
|
|
371
|
+
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
|
|
372
|
+
configurable: true,
|
|
373
|
+
get(this: HTMLElement) {
|
|
374
|
+
if (this.classList.contains("hk-modal-content")) {
|
|
375
|
+
return Math.min(content + 96, cap);
|
|
376
|
+
}
|
|
377
|
+
if (this.classList.contains("hk-modal-body-inner")) return content;
|
|
378
|
+
return 0;
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function restoreBox(): void {
|
|
384
|
+
if (realOffsetHeight) {
|
|
385
|
+
Object.defineProperty(
|
|
386
|
+
HTMLElement.prototype,
|
|
387
|
+
"offsetHeight",
|
|
388
|
+
realOffsetHeight,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function stubClip(): void {
|
|
394
|
+
const real = window.getComputedStyle.bind(window);
|
|
395
|
+
vi.stubGlobal("getComputedStyle", (el: Element, ...rest: unknown[]) => {
|
|
396
|
+
const style = real(el as Element, ...(rest as []));
|
|
397
|
+
if (!(el instanceof HTMLElement)) return style;
|
|
398
|
+
if (el.classList.contains("hk-modal-content")) {
|
|
399
|
+
return {
|
|
400
|
+
...style,
|
|
401
|
+
transitionDuration: "0.3s",
|
|
402
|
+
getPropertyValue: (name: string) =>
|
|
403
|
+
name === "--hk-sheet-morph" ? "clip" : style.getPropertyValue(name),
|
|
404
|
+
} as CSSStyleDeclaration;
|
|
405
|
+
}
|
|
406
|
+
return style;
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
afterEach(() => {
|
|
411
|
+
restoreBox();
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
it("unfreezes via the safety timer when the settle never arrives", async () => {
|
|
421
|
+
// A flow unmounted mid-slide never sends the morph event — the
|
|
422
|
+
// safety unfreeze must not only drop the flag but FLUSH the
|
|
423
|
+
// measurement the freeze dropped (adversarial finding: without the
|
|
424
|
+
// flush the sheet sits at its old height until the next content
|
|
425
|
+
// change).
|
|
426
|
+
vi.useFakeTimers();
|
|
427
|
+
freezeRaf();
|
|
428
|
+
stubDurations();
|
|
338
429
|
stubClip();
|
|
339
430
|
stubBox(500, Number.POSITIVE_INFINITY);
|
|
431
|
+
const roInstances: Array<{ callback: () => void }> = [];
|
|
432
|
+
vi.stubGlobal("ResizeObserver", class {
|
|
433
|
+
callback: () => void;
|
|
434
|
+
constructor(cb: () => void) {
|
|
435
|
+
this.callback = cb;
|
|
436
|
+
roInstances.push(this);
|
|
437
|
+
}
|
|
438
|
+
observe() {}
|
|
439
|
+
disconnect() {}
|
|
440
|
+
});
|
|
441
|
+
const rig = await mountRig();
|
|
442
|
+
rig.open.value = true;
|
|
443
|
+
await vi.advanceTimersByTimeAsync(700);
|
|
444
|
+
const frame = rig.frameEl()!;
|
|
445
|
+
expect(frame.style.height).toBe("596px");
|
|
340
446
|
|
|
341
|
-
// The
|
|
342
|
-
//
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
447
|
+
// The slide window freezes; the content grew and the observer event
|
|
448
|
+
// was dropped (gated); no morph event will ever come.
|
|
449
|
+
rig.announceSwap({ delta: 0, durationMs: 300, phase: "swap" });
|
|
450
|
+
await nextTick();
|
|
451
|
+
content = 560;
|
|
452
|
+
roInstances[0]!.callback();
|
|
453
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
454
|
+
expect(frame.style.height).toBe("596px");
|
|
455
|
+
|
|
456
|
+
// Past ms + 650 the safety unfreeze runs AND flushes: the pin
|
|
457
|
+
// reflects the growth without any event.
|
|
458
|
+
await vi.advanceTimersByTimeAsync(700);
|
|
459
|
+
expect(frame.style.height).toBe("656px");
|
|
460
|
+
// …and a later morph event still works (no stranded freeze).
|
|
461
|
+
rig.announceSwap({ delta: -40, durationMs: 300, phase: "morph" });
|
|
462
|
+
await nextTick();
|
|
463
|
+
expect(rig.morphToken()).toBe("300ms");
|
|
464
|
+
await vi.advanceTimersByTimeAsync(700);
|
|
465
|
+
expect(rig.morphToken()).toBe("");
|
|
466
|
+
restoreBox();
|
|
467
|
+
});
|
|
468
|
+
it("gates the observer while the slide window freezes the sheet", async () => {
|
|
469
|
+
// Round 16 wiring witness (the adversarial round's MuD survived
|
|
470
|
+
// against everything else): the "swap" event must route into the
|
|
471
|
+
// morph's deferRemeasure gate — an observer-driven growth mid-slide
|
|
472
|
+
// leaves the pin untouched, and the very same observer path measures
|
|
473
|
+
// again once the morph edge unfroze it (the last clause also proves
|
|
474
|
+
// the observer path is ALIVE in this harness, so the hold clause is
|
|
475
|
+
// not vacuously green).
|
|
476
|
+
vi.useFakeTimers();
|
|
477
|
+
stubDurations();
|
|
478
|
+
stubClip();
|
|
479
|
+
stubBox(500, Number.POSITIVE_INFINITY);
|
|
480
|
+
const roInstances: Array<{ callback: () => void }> = [];
|
|
481
|
+
vi.stubGlobal("ResizeObserver", class {
|
|
482
|
+
callback: () => void;
|
|
483
|
+
constructor(cb: () => void) {
|
|
484
|
+
this.callback = cb;
|
|
485
|
+
roInstances.push(this);
|
|
486
|
+
}
|
|
487
|
+
observe() {}
|
|
488
|
+
disconnect() {}
|
|
350
489
|
});
|
|
490
|
+
// No rAF stub: happy-dom maps rAF onto its timer queue, so the
|
|
491
|
+
// debounce AND the frame hops all run under the fake clock.
|
|
492
|
+
const rig = await mountRig();
|
|
493
|
+
rig.open.value = true;
|
|
494
|
+
await vi.advanceTimersByTimeAsync(700);
|
|
495
|
+
const frame = rig.frameEl()!;
|
|
496
|
+
expect(frame.style.height).toBe("596px");
|
|
497
|
+
|
|
498
|
+
// The slide window: frozen. The observer fires; the pin holds.
|
|
499
|
+
rig.announceSwap({ delta: 0, durationMs: 300, phase: "swap" });
|
|
500
|
+
await nextTick();
|
|
501
|
+
content = 560;
|
|
502
|
+
roInstances[0]!.callback();
|
|
503
|
+
await vi.advanceTimersByTimeAsync(700);
|
|
504
|
+
expect(frame.style.height).toBe("596px");
|
|
351
505
|
|
|
506
|
+
// The morph edge unfreezes and measures the accumulated growth.
|
|
507
|
+
rig.announceSwap({ delta: 24, durationMs: 300, phase: "morph" });
|
|
508
|
+
await nextTick();
|
|
509
|
+
expect(frame.style.height).not.toBe("596px");
|
|
510
|
+
|
|
511
|
+
// Harness validity + unfreeze proof: the SAME observer path now
|
|
512
|
+
// moves the pin without any event.
|
|
513
|
+
content = 700;
|
|
514
|
+
roInstances[0]!.callback();
|
|
515
|
+
await vi.advanceTimersByTimeAsync(700);
|
|
516
|
+
expect(frame.style.height).toBe("796px");
|
|
517
|
+
restoreBox();
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it("rides its chrome with the staged fold", async () => {
|
|
521
|
+
// Round 16: only the host chrome rides (header/subheader/body
|
|
522
|
+
// block) — the per-swap registry is retired with the concurrent
|
|
523
|
+
// choreography. This pins the modal-level collection + the resident
|
|
524
|
+
// promotion (round 15); the ride's flip/settle lifecycle is
|
|
525
|
+
// unit-witnessed in useSizeMorph.test.ts.
|
|
526
|
+
vi.useFakeTimers();
|
|
527
|
+
freezeRaf();
|
|
528
|
+
stubClip();
|
|
529
|
+
stubBox(500, Number.POSITIVE_INFINITY);
|
|
352
530
|
const container = document.createElement("div");
|
|
353
531
|
document.body.appendChild(container);
|
|
354
532
|
containers.push(container);
|
|
@@ -362,7 +540,7 @@ describe("HkModal publishes its own fold for the step flow", () => {
|
|
|
362
540
|
title: "ride-test",
|
|
363
541
|
"onUpdate:modelValue": (v: boolean) => { open.value = v; },
|
|
364
542
|
}, {
|
|
365
|
-
default: () => h("div", { class: "swap-body" },
|
|
543
|
+
default: () => h("div", { class: "swap-body" }, "step content"),
|
|
366
544
|
});
|
|
367
545
|
},
|
|
368
546
|
});
|
|
@@ -376,60 +554,28 @@ describe("HkModal publishes its own fold for the step flow", () => {
|
|
|
376
554
|
const frame = () => document.querySelector<HTMLElement>(".hk-modal-content")!;
|
|
377
555
|
const header = () => document.querySelector<HTMLElement>(".hk-modal-header")!;
|
|
378
556
|
const bodyBlock = () => document.querySelector<HTMLElement>(".hk-modal-body")!;
|
|
379
|
-
const riderEl = () => document.querySelector<HTMLElement>(".rider-el")!;
|
|
380
|
-
expect(lateRegistry).not.toBeNull();
|
|
381
|
-
// Register only AFTER the surface armed — the per-swap timing.
|
|
382
|
-
lateRegistry!.register({ el: riderEl(), counter: true });
|
|
383
557
|
|
|
384
|
-
//
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
frames.push(cb);
|
|
388
|
-
return frames.length;
|
|
389
|
-
});
|
|
390
|
-
vi.stubGlobal("cancelAnimationFrame", () => {});
|
|
558
|
+
// Resident promotion from the arm moment (round 15).
|
|
559
|
+
expect(header().style.willChange).toBe("transform");
|
|
560
|
+
expect(bodyBlock().style.willChange).toBe("transform");
|
|
391
561
|
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
// the registered counter at the mirrored negative.
|
|
562
|
+
// The morph window (round 16 protocol) stages the fold: the chrome
|
|
563
|
+
// sits at its pre-growth offset, instantly.
|
|
395
564
|
content = 700;
|
|
396
565
|
document
|
|
397
566
|
.querySelector<HTMLElement>(".hk-modal-body .swap-body")!
|
|
398
567
|
.dispatchEvent(
|
|
399
568
|
new CustomEvent(STEPFLOW_SWAP_EVENT, {
|
|
400
569
|
bubbles: true,
|
|
401
|
-
detail: { delta: 200, durationMs:
|
|
570
|
+
detail: { delta: 200, durationMs: 300, phase: "morph" },
|
|
402
571
|
}),
|
|
403
572
|
);
|
|
404
573
|
await nextTick();
|
|
405
574
|
expect(header().style.transform).toBe("translateY(200px)");
|
|
406
575
|
expect(bodyBlock().style.transform).toBe("translateY(200px)");
|
|
407
|
-
expect(riderEl().style.transform).toBe("translateY(-200px)");
|
|
408
|
-
expect(header().style.willChange).toBe("transform");
|
|
409
576
|
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
|
|
413
|
-
for (const cb of frames.splice(0)) cb(i * 16);
|
|
414
|
-
await vi.advanceTimersByTimeAsync(16);
|
|
415
|
-
}
|
|
416
|
-
expect(header().style.transform).toBe("translateY(0px)");
|
|
417
|
-
expect(riderEl().style.transform).toBe("translateY(0px)");
|
|
418
|
-
expect(header().style.transition).toMatch(/^transform 0\.3s /);
|
|
419
|
-
|
|
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.
|
|
425
|
-
const ev = new Event("transitionend");
|
|
426
|
-
Object.defineProperty(ev, "propertyName", { value: "clip-path" });
|
|
427
|
-
frame().dispatchEvent(ev);
|
|
428
|
-
expect(header().style.transform).toBe("");
|
|
429
|
-
expect(bodyBlock().style.transform).toBe("");
|
|
430
|
-
expect(riderEl().style.transform).toBe("");
|
|
431
|
-
expect(header().style.willChange).toBe("transform");
|
|
432
|
-
expect(bodyBlock().style.willChange).toBe("transform");
|
|
433
|
-
expect(riderEl().style.willChange).toBe("");
|
|
577
|
+
// (The landing's release + resident survival are unit-witnessed in
|
|
578
|
+
// useSizeMorph.test.ts — this rig's frozen rAF never starts the
|
|
579
|
+
// sweep, so there is no listener to land here.)
|
|
434
580
|
});
|
|
435
581
|
});
|
|
@@ -12,7 +12,6 @@ import { useSurfaceTransition } from "../composables/useSurfaceTransition";
|
|
|
12
12
|
import { useSurfaceMachine } from "../composables/useSurfaceMachine";
|
|
13
13
|
import { useSurfaceContentHold } from "../composables/useSurfaceContentHold";
|
|
14
14
|
import { useSizeMorph, type RideEntry } from "../composables/useSizeMorph";
|
|
15
|
-
import { provideSheetRide } from "../runtime/sheetRide";
|
|
16
15
|
|
|
17
16
|
import { STEPFLOW_SWAP_EVENT } from "./HkStepFlow";
|
|
18
17
|
|
|
@@ -211,12 +210,15 @@ export default defineComponent({
|
|
|
211
210
|
// running animation (2026-09-16 chest field report — the frame
|
|
212
211
|
// snapped 459→697px mid-unfold). The open phase edge flushes the
|
|
213
212
|
// deferred growth with an animated remeasure.
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
|
|
213
|
+
// The freeze flag (round 16): while a stepflow SLIDE is in flight the
|
|
214
|
+
// sheet's height must not chase the flow's changing cell — the gate
|
|
215
|
+
// below also returns true then, and the morph window's own event
|
|
216
|
+
// flushes the frozen change with an explicit remeasure.
|
|
217
|
+
let swapFrozen = false;
|
|
218
|
+
let swapFreezeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
218
219
|
const morph = useSizeMorph(contentRef, innerRef, {
|
|
219
220
|
deferRemeasure: () =>
|
|
221
|
+
swapFrozen ||
|
|
220
222
|
machine.phase.value === "openingFrom" ||
|
|
221
223
|
machine.phase.value === "openingTo",
|
|
222
224
|
// The sheet's chrome and content block ride the fold edge in
|
|
@@ -245,7 +247,6 @@ export default defineComponent({
|
|
|
245
247
|
// untransformed; the block's own poke below the frame lands
|
|
246
248
|
// off-screen on a bottom-docked sheet.
|
|
247
249
|
if (bodyRef.value) out.push({ el: bodyRef.value });
|
|
248
|
-
out.push(...sheetRide.snapshot());
|
|
249
250
|
return out;
|
|
250
251
|
},
|
|
251
252
|
// Republish the morph's real sweep span and its landing on the body
|
|
@@ -269,18 +270,19 @@ export default defineComponent({
|
|
|
269
270
|
let previouslyFocused: HTMLElement | null = null;
|
|
270
271
|
let unmounted = false;
|
|
271
272
|
|
|
272
|
-
// HkStepFlow swaps dispatch STEPFLOW_SWAP_EVENT (bubbling)
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
273
|
+
// HkStepFlow swaps dispatch STEPFLOW_SWAP_EVENT (bubbling) with the
|
|
274
|
+
// two-window protocol (2026-09-23 user directive, round 16): the
|
|
275
|
+
// slide window announces `{phase: "swap"}` — the sheet FREEZES its
|
|
276
|
+
// height (observer-driven remeasures gated; a safety timer unfreezes
|
|
277
|
+
// if the settle never arrives, e.g. the flow unmounts mid-slide) —
|
|
278
|
+
// and the morph window that follows announces `{phase: "morph",
|
|
279
|
+
// delta, durationMs}` once the content settled: the freeze lifts and
|
|
280
|
+
// the remeasure short-circuits the settle debounce, overriding the
|
|
281
|
+
// sheet's own sweep duration for this one morph (the stylesheet
|
|
282
|
+
// default stays the 0.3s family standard for every other morph; the
|
|
283
|
+
// override is cleared on the same duration+grace watchdog the sweep
|
|
284
|
+
// itself uses). `{phase: "instant"}` (reduced motion, no-transition
|
|
285
|
+
// runtimes) skips the freeze entirely and just pokes the sheet.
|
|
284
286
|
let morphDurationTimer: ReturnType<typeof setTimeout> | null = null;
|
|
285
287
|
const clearMorphDuration = (): void => {
|
|
286
288
|
if (morphDurationTimer !== null) {
|
|
@@ -289,17 +291,47 @@ export default defineComponent({
|
|
|
289
291
|
}
|
|
290
292
|
contentRef.value?.style.removeProperty("--hk-modal-morph-duration");
|
|
291
293
|
};
|
|
294
|
+
const clearSwapFreeze = (flush = false): void => {
|
|
295
|
+
const timed = swapFreezeTimer !== null;
|
|
296
|
+
swapFrozen = false;
|
|
297
|
+
if (swapFreezeTimer !== null) {
|
|
298
|
+
clearTimeout(swapFreezeTimer);
|
|
299
|
+
swapFreezeTimer = null;
|
|
300
|
+
}
|
|
301
|
+
// The safety unfreeze (no morph event ever arrived — e.g. the flow
|
|
302
|
+
// unmounted mid-slide) must not just drop the flag: the slide-time
|
|
303
|
+
// content change was DROPPED, not queued, so without a flush here
|
|
304
|
+
// the sheet would sit at its old height until the next content
|
|
305
|
+
// change (adversarial round finding).
|
|
306
|
+
if (flush && timed) morph.remeasure();
|
|
307
|
+
};
|
|
292
308
|
const onStepflowSwap = (event: Event): void => {
|
|
293
309
|
if (machine.phase.value !== "open") {
|
|
294
310
|
// A swap announced while the surface is closing/opening: drop any
|
|
295
|
-
// override a previous
|
|
296
|
-
// into the next open
|
|
297
|
-
|
|
311
|
+
// override or freeze a previous window left behind instead of
|
|
312
|
+
// letting it ride into the next open.
|
|
313
|
+
clearSwapFreeze();
|
|
298
314
|
clearMorphDuration();
|
|
299
315
|
return;
|
|
300
316
|
}
|
|
317
|
+
const detail = (event as CustomEvent<{
|
|
318
|
+
phase?: string;
|
|
319
|
+
durationMs?: number;
|
|
320
|
+
}>).detail;
|
|
321
|
+
if (detail?.phase === "swap") {
|
|
322
|
+
// The slide window: hold the pin no matter what the flow's cell
|
|
323
|
+
// does, and arm the safety unfreeze (the morph window's own
|
|
324
|
+
// event clears it earlier on the happy path).
|
|
325
|
+
swapFrozen = true;
|
|
326
|
+
if (swapFreezeTimer !== null) clearTimeout(swapFreezeTimer);
|
|
327
|
+
const ms = typeof detail.durationMs === "number" ? detail.durationMs : 300;
|
|
328
|
+
swapFreezeTimer = setTimeout(() => clearSwapFreeze(true), ms + 650);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
// Morph / instant: the content settled — unfreeze and measure now.
|
|
332
|
+
clearSwapFreeze();
|
|
301
333
|
const frame = contentRef.value;
|
|
302
|
-
const ms =
|
|
334
|
+
const ms = detail?.durationMs;
|
|
303
335
|
if (frame && typeof ms === "number" && ms > 0) {
|
|
304
336
|
frame.style.setProperty("--hk-modal-morph-duration", `${ms}ms`);
|
|
305
337
|
morph.remeasure();
|
|
@@ -311,6 +343,7 @@ export default defineComponent({
|
|
|
311
343
|
};
|
|
312
344
|
onBeforeUnmount(() => {
|
|
313
345
|
bodyRef.value?.removeEventListener(STEPFLOW_SWAP_EVENT, onStepflowSwap);
|
|
346
|
+
clearSwapFreeze();
|
|
314
347
|
clearMorphDuration();
|
|
315
348
|
});
|
|
316
349
|
|